From 6af2cc376b5242ba9a421332caf46b3ae3b724a0 Mon Sep 17 00:00:00 2001 From: SomeeName <168603991+SomeeName@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:31:11 +0200 Subject: [PATCH 1/5] Support 7 string tuning and chord templates in XML --- src/Rocksmith2014.XML/ChordTemplate.cs | 263 ++++++++++-------- .../InstrumentalArrangement.cs | 17 ++ src/Rocksmith2014.XML/StringLayout.cs | 108 +++++++ src/Rocksmith2014.XML/Tuning.cs | 35 ++- .../ChordTemplateTests.cs | 156 +++++++++++ .../StringLayoutTests.cs | 65 +++++ 6 files changed, 514 insertions(+), 130 deletions(-) create mode 100644 src/Rocksmith2014.XML/StringLayout.cs create mode 100644 tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs create mode 100644 tests/Rocksmith2014.XML.Tests/StringLayoutTests.cs diff --git a/src/Rocksmith2014.XML/ChordTemplate.cs b/src/Rocksmith2014.XML/ChordTemplate.cs index 6594ddc9..fddeca3a 100644 --- a/src/Rocksmith2014.XML/ChordTemplate.cs +++ b/src/Rocksmith2014.XML/ChordTemplate.cs @@ -8,9 +8,15 @@ namespace Rocksmith2014.XML { /// /// Represents a chord template. + /// + /// Frets and fingers use storage indices: 0–5 are the stock strings, 6 is the low B. + /// /// public sealed class ChordTemplate : IXmlSerializable { + private const string FingerPrefix = "finger"; + private const string FretPrefix = "fret"; + /// /// Gets or sets the name of the chord. /// @@ -34,39 +40,83 @@ public string DisplayName public bool IsArpeggio { get; private set; } /// - /// The fingering of the chord template. + /// The fingering of the chord template, indexed by string. -1 means the string isnt used. /// - public readonly sbyte[] Fingers = { -1, -1, -1, -1, -1, -1 }; + public sbyte[] Fingers { get; private set; } /// - /// The frets of the chord template. + /// The frets of the chord template, indexed by string. -1 means the string isnt used. /// - public readonly sbyte[] Frets = { -1, -1, -1, -1, -1, -1 }; + public sbyte[] Frets { get; private set; } private string displayName = string.Empty; /// - /// Creates a new chord template. + /// Gets the number of strings of the template. + /// + public int StringCount => Frets.Length; + + /// + /// Creates a new six string chord template. + /// + public ChordTemplate() : this(StringLayout.StockStringCount) { } + + /// + /// Creates a new empty chord template for the given string count. /// - public ChordTemplate() { } + /// The number of strings (6 or 7). + public ChordTemplate(int stringCount) + { + StringLayout.ValidateStringCount(stringCount); + Fingers = CreateUnused(stringCount); + Frets = CreateUnused(stringCount); + } /// /// Creates a new chord template with the given properties. + /// The string count is the length of the longer array, but at least six. /// /// The name of the chord. /// The display name of the chord. - /// The fingering of the chord. - /// The frets of the chord. + /// The fingering of the chord, indexed by string. + /// The frets of the chord, indexed by string. public ChordTemplate(string chordName, string displayName, sbyte[] fingers, sbyte[] frets) { + if (fingers is null) + throw new ArgumentNullException(nameof(fingers)); + if (frets is null) + throw new ArgumentNullException(nameof(frets)); + + var stringCount = Math.Max(StringLayout.StockStringCount, Math.Max(fingers.Length, frets.Length)); + StringLayout.ValidateStringCount(stringCount); + + Fingers = CreateUnused(stringCount); + Frets = CreateUnused(stringCount); + Array.Copy(fingers, Fingers, fingers.Length); + Array.Copy(frets, Frets, frets.Length); + Name = chordName; DisplayName = displayName; - Array.Copy(frets, Frets, 6); - Array.Copy(fingers, Fingers, 6); } /// - /// Sets the fingering of the chord template. + /// Expands the template to the given string count. Added strings are unused (-1). + /// Does nothing if the template already has at least that many strings. + /// + /// The target string count (6 or 7). + public void EnsureStringCount(int stringCount) + { + StringLayout.ValidateStringCount(stringCount); + + if (stringCount <= StringCount) + return; + + Fingers = Resize(Fingers, stringCount); + Frets = Resize(Frets, stringCount); + } + + /// + /// Sets the fingering of the stock six strings of the chord template. /// /// The low E string in standard tuning. /// The A string in standard tuning. @@ -85,7 +135,7 @@ public void SetFingering(sbyte lowE, sbyte A, sbyte D, sbyte G, sbyte B, sbyte h } /// - /// Sets the frets of the chord template. + /// Sets the frets of the stock six strings of the chord template. /// /// The low E string in standard tuning. /// The A string in standard tuning. @@ -107,7 +157,7 @@ public override string ToString() { string result = $"Name: \"{Name}\", Display Name: \"{DisplayName}\", "; - for (int i = 0; i < 6; i++) + for (int i = 0; i < StringCount; i++) { if (Frets[i] != -1) result += $"Fret{i}={Frets[i]} "; @@ -119,6 +169,60 @@ public override string ToString() return result; } + private static sbyte[] CreateUnused(int count) + { + var array = new sbyte[count]; + Array.Fill(array, (sbyte)-1); + return array; + } + + private static sbyte[] Resize(sbyte[] source, int count) + { + var result = CreateUnused(count); + Array.Copy(source, result, source.Length); + return result; + } + + private static bool TryGetStringIndex(string attributeName, string prefix, out int index) + { + index = -1; + + if (attributeName.Length != prefix.Length + 1 + || !attributeName.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + var digit = attributeName[attributeName.Length - 1]; + if (digit < '0' || digit > '9') + return false; + + index = digit - '0'; + return true; + } + + private void EnsureIndex(int index) + { + if (index >= StringLayout.MaxStringCount) + { + throw new NotSupportedException( + $"Chord templates with more than {StringLayout.MaxStringCount} strings are not supported (found string index {index})."); + } + + if (index >= StringCount) + EnsureStringCount(index + 1); + } + + private static void WriteValue(XmlWriter writer, string prefix, int index, sbyte value, bool alwaysWrite) + { + var name = prefix + index.ToString(NumberFormatInfo.InvariantInfo); + + if (value != -1) + writer.WriteAttributeString(name, value.ToString(NumberFormatInfo.InvariantInfo)); + else if (alwaysWrite || !InstrumentalArrangement.UseAbridgedXml) + writer.WriteAttributeString(name, "-1"); + } + #region IXmlSerializable Implementation XmlSchema? IXmlSerializable.GetSchema() => null; @@ -128,52 +232,25 @@ void IXmlSerializable.ReadXml(XmlReader reader) for (int i = 0; i < reader.AttributeCount; i++) { reader.MoveToAttribute(i); + var name = reader.Name; - switch (reader.Name) + if (name == "chordName") + { + Name = reader.Value; + } + else if (name == "displayName") + { + DisplayName = reader.Value; + } + else if (TryGetStringIndex(name, FingerPrefix, out int fingerIndex)) { - case "chordName": - Name = reader.Value; - break; - case "displayName": - DisplayName = reader.Value; - break; - case "finger0": - Fingers[0] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "finger1": - Fingers[1] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "finger2": - Fingers[2] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "finger3": - Fingers[3] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "finger4": - Fingers[4] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "finger5": - Fingers[5] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - - case "fret0": - Frets[0] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "fret1": - Frets[1] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "fret2": - Frets[2] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "fret3": - Frets[3] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "fret4": - Frets[4] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; - case "fret5": - Frets[5] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); - break; + EnsureIndex(fingerIndex); + Fingers[fingerIndex] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); + } + else if (TryGetStringIndex(name, FretPrefix, out int fretIndex)) + { + EnsureIndex(fretIndex); + Frets[fretIndex] = sbyte.Parse(reader.Value, NumberFormatInfo.InvariantInfo); } } @@ -187,65 +264,19 @@ void IXmlSerializable.WriteXml(XmlWriter writer) writer.WriteAttributeString("chordName", Name); writer.WriteAttributeString("displayName", DisplayName); - if (Fingers[0] != -1) - writer.WriteAttributeString("finger0", Fingers[0].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger0", "-1"); - - if (Fingers[1] != -1) - writer.WriteAttributeString("finger1", Fingers[1].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger1", "-1"); - - if (Fingers[2] != -1) - writer.WriteAttributeString("finger2", Fingers[2].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger2", "-1"); - - if (Fingers[3] != -1) - writer.WriteAttributeString("finger3", Fingers[3].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger3", "-1"); - - if (Fingers[4] != -1) - writer.WriteAttributeString("finger4", Fingers[4].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger4", "-1"); - - if (Fingers[5] != -1) - writer.WriteAttributeString("finger5", Fingers[5].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("finger5", "-1"); - - if (Frets[0] != -1) - writer.WriteAttributeString("fret0", Frets[0].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret0", "-1"); - - if (Frets[1] != -1) - writer.WriteAttributeString("fret1", Frets[1].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret1", "-1"); - - if (Frets[2] != -1) - writer.WriteAttributeString("fret2", Frets[2].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret2", "-1"); - - if (Frets[3] != -1) - writer.WriteAttributeString("fret3", Frets[3].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret3", "-1"); - - if (Frets[4] != -1) - writer.WriteAttributeString("fret4", Frets[4].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret4", "-1"); - - if (Frets[5] != -1) - writer.WriteAttributeString("fret5", Frets[5].ToString(NumberFormatInfo.InvariantInfo)); - else if (!InstrumentalArrangement.UseAbridgedXml) - writer.WriteAttributeString("fret5", "-1"); + // Stock strings: same attribute order and abridging rules as before. + for (int i = 0; i < StringLayout.StockStringCount; i++) + WriteValue(writer, FingerPrefix, i, Fingers[i], alwaysWrite: false); + + for (int i = 0; i < StringLayout.StockStringCount; i++) + WriteValue(writer, FretPrefix, i, Frets[i], alwaysWrite: false); + + // Extended strings are always written so that the string count survives abridged XML. + for (int i = StringLayout.StockStringCount; i < StringCount; i++) + { + WriteValue(writer, FingerPrefix, i, Fingers[i], alwaysWrite: true); + WriteValue(writer, FretPrefix, i, Frets[i], alwaysWrite: true); + } } #endregion diff --git a/src/Rocksmith2014.XML/InstrumentalArrangement.cs b/src/Rocksmith2014.XML/InstrumentalArrangement.cs index f9a707eb..0a6e18d5 100644 --- a/src/Rocksmith2014.XML/InstrumentalArrangement.cs +++ b/src/Rocksmith2014.XML/InstrumentalArrangement.cs @@ -101,6 +101,22 @@ public class InstrumentalArrangement : IXmlSerializable /// public List Levels { get; set; } = new List(); + /// + /// Expands every chord template to the string count of the tuning. + /// Missing columns are unused (-1). + /// Does nothing if the tuning has an unsupported string count. + /// + public void EnsureChordTemplateStringCounts() + { + var stringCount = MetaData.Tuning.Strings.Length; + + if (!StringLayout.IsSupportedStringCount(stringCount)) + return; + + foreach (var template in ChordTemplates) + template.EnsureStringCount(stringCount); + } + /// /// Saves this Rocksmith 2014 arrangement into the given file. /// @@ -539,6 +555,7 @@ void IXmlSerializable.ReadXml(XmlReader reader) } reader.ReadEndElement(); + EnsureChordTemplateStringCounts(); } void IXmlSerializable.WriteXml(XmlWriter writer) diff --git a/src/Rocksmith2014.XML/StringLayout.cs b/src/Rocksmith2014.XML/StringLayout.cs new file mode 100644 index 00000000..bfaa1dde --- /dev/null +++ b/src/Rocksmith2014.XML/StringLayout.cs @@ -0,0 +1,108 @@ +using System; + +namespace Rocksmith2014.XML +{ + /// + /// Describes string storage order for instrumental arrangements. + /// Seven-string guitar stores its additional low B at index 6. + /// + /// Storage indices 0–5 keep their stock Rocksmith meaning (low E … high E). + /// Storage index 6 is the additional low B string of a seven-string guitar. + /// Musical algorithms must use or + /// instead of assuming that a lower storage index means a lower string. + /// + /// + public static class StringLayout + { + /// + /// The number of strings in a stock Rocksmith 2014 guitar arrangement. + /// + public const int StockStringCount = 6; + + /// + /// The maximum number of strings supported by the library. + /// + public const int MaxStringCount = 7; + + // E2 A2 D3 G3 B3 E4, then the extended low B1. + private static readonly int[] StandardMidiNotes = [40, 45, 50, 55, 59, 64, 35]; + + private static readonly int[] SixStringLowToHigh = [0, 1, 2, 3, 4, 5]; + private static readonly int[] SevenStringLowToHigh = [6, 0, 1, 2, 3, 4, 5]; + + /// + /// Returns true if the string count is supported (6 or 7). + /// + public static bool IsSupportedStringCount(int stringCount) + => stringCount is StockStringCount or MaxStringCount; + + /// + /// Throws if the string count is not supported. + /// + public static void ValidateStringCount(int stringCount) + { + if (!IsSupportedStringCount(stringCount)) + { + throw new ArgumentOutOfRangeException( + nameof(stringCount), + stringCount, + $"Only {StockStringCount}-string and {MaxStringCount}-string arrangements are supported."); + } + } + + /// + /// Returns the storage indices of the strings ordered from the lowest pitched string to the highest. + /// + public static int[] LowToHigh(int stringCount) + { + ValidateStringCount(stringCount); + var order = stringCount == MaxStringCount ? SevenStringLowToHigh : SixStringLowToHigh; + return (int[])order.Clone(); + } + + /// + /// Returns the position of the string counted from the lowest pitched string (0 = lowest). + /// + public static int MusicalRank(int stringIndex, int stringCount) + { + ValidateStringCount(stringCount); + + if (stringIndex < 0 || stringIndex >= stringCount) + throw new ArgumentOutOfRangeException(nameof(stringIndex), stringIndex, "String index is outside the string layout."); + + if (stringCount == MaxStringCount) + return stringIndex == StockStringCount ? 0 : stringIndex + 1; + + return stringIndex; + } + + /// + /// Returns the MIDI note of the open string in E standard tuning (B1 for the seventh string). + /// + public static int StandardMidiNote(int stringIndex) + { + if (stringIndex < 0 || stringIndex >= StandardMidiNotes.Length) + throw new ArgumentOutOfRangeException(nameof(stringIndex), stringIndex, "String index is outside the supported range."); + + return StandardMidiNotes[stringIndex]; + } + + /// + /// Reorders values stored by string index into low-to-high musical order. + /// The length of the input array defines the string count. + /// + public static T[] ToLowToHigh(T[] valuesByStringIndex) + { + if (valuesByStringIndex is null) + throw new ArgumentNullException(nameof(valuesByStringIndex)); + + var order = LowToHigh(valuesByStringIndex.Length); + var result = new T[order.Length]; + + for (var i = 0; i < order.Length; i++) + result[i] = valuesByStringIndex[order[i]]; + + return result; + } + } +} diff --git a/src/Rocksmith2014.XML/Tuning.cs b/src/Rocksmith2014.XML/Tuning.cs index cf92dbf6..dff50b17 100644 --- a/src/Rocksmith2014.XML/Tuning.cs +++ b/src/Rocksmith2014.XML/Tuning.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; @@ -13,7 +14,7 @@ public sealed class Tuning : IXmlSerializable /// /// The tuning of each string, represented as an offset from E Standard. /// - public readonly short[] Strings = new short[6]; + public short[] Strings { get; set; } = new short[6]; /// /// Sets the tuning offset of the strings. @@ -43,24 +44,30 @@ public override string ToString() void IXmlSerializable.ReadXml(XmlReader reader) { - Strings[0] = short.Parse(reader.GetAttribute("string0"), NumberFormatInfo.InvariantInfo); - Strings[1] = short.Parse(reader.GetAttribute("string1"), NumberFormatInfo.InvariantInfo); - Strings[2] = short.Parse(reader.GetAttribute("string2"), NumberFormatInfo.InvariantInfo); - Strings[3] = short.Parse(reader.GetAttribute("string3"), NumberFormatInfo.InvariantInfo); - Strings[4] = short.Parse(reader.GetAttribute("string4"), NumberFormatInfo.InvariantInfo); - Strings[5] = short.Parse(reader.GetAttribute("string5"), NumberFormatInfo.InvariantInfo); + for (var i = 0; i < 6; i++) + { + var attribute = $"string{i}"; + var value = reader.GetAttribute(attribute) ?? throw new XmlException($"Missing required tuning attribute '{attribute}'."); + + Strings[i] = short.Parse(value, NumberFormatInfo.InvariantInfo); + } + + var seventhString = reader.GetAttribute("string6"); + if (seventhString is not null) + { + var strings = Strings; + Array.Resize(ref strings, 7); + Strings = strings; + Strings[6] = short.Parse(seventhString, NumberFormatInfo.InvariantInfo); + } reader.ReadStartElement(); } void IXmlSerializable.WriteXml(XmlWriter writer) { - writer.WriteAttributeString("string0", Strings[0].ToString(NumberFormatInfo.InvariantInfo)); - writer.WriteAttributeString("string1", Strings[1].ToString(NumberFormatInfo.InvariantInfo)); - writer.WriteAttributeString("string2", Strings[2].ToString(NumberFormatInfo.InvariantInfo)); - writer.WriteAttributeString("string3", Strings[3].ToString(NumberFormatInfo.InvariantInfo)); - writer.WriteAttributeString("string4", Strings[4].ToString(NumberFormatInfo.InvariantInfo)); - writer.WriteAttributeString("string5", Strings[5].ToString(NumberFormatInfo.InvariantInfo)); + for (var i = 0; i < Strings.Length; i++) + writer.WriteAttributeString($"string{i}", Strings[i].ToString(NumberFormatInfo.InvariantInfo)); } #endregion diff --git a/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs b/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs new file mode 100644 index 00000000..21c7a859 --- /dev/null +++ b/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; + +using FluentAssertions; + +using Xunit; + +namespace Rocksmith2014.XML.Tests +{ + public static class ChordTemplateTests + { + private static string TempXmlPath() + => Path.Combine(Path.GetTempPath(), $"chordtemplate-{Guid.NewGuid():N}.xml"); + + private static InstrumentalArrangement SaveAndLoad(InstrumentalArrangement arrangement, bool abridged) + { + var path = TempXmlPath(); + try + { + arrangement.Save(path, abridged); + return InstrumentalArrangement.Load(path); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Fact] + public static void DefaultTemplateHasSixUnusedStrings() + { + var template = new ChordTemplate(); + + template.StringCount.Should().Be(6); + template.Frets.Should().AllBeEquivalentTo((sbyte)-1); + template.Fingers.Should().AllBeEquivalentTo((sbyte)-1); + } + + [Fact] + public static void ConstructorWithSevenValuesCreatesSevenStringTemplate() + { + var template = new ChordTemplate( + "B5", "B5", + [3, -1, -1, -1, -1, -1, 1], + [2, -1, -1, -1, -1, -1, 0]); + + template.StringCount.Should().Be(7); + template.Frets[6].Should().Be(0); + template.Fingers[6].Should().Be(1); + } + + [Fact] + public static void EnsureStringCountOnlyGrows() + { + var template = new ChordTemplate("E", "E", new sbyte[6], new sbyte[6]); + + template.EnsureStringCount(7); + template.EnsureStringCount(6); + + template.StringCount.Should().Be(7); + template.Frets[6].Should().Be(-1); + template.Frets[0].Should().Be(0); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public static void SevenStringTemplateRoundTripsThroughXml(bool abridged) + { + var arrangement = new InstrumentalArrangement(); + + arrangement.MetaData.Tuning.Strings = new short[] { 0, 0, 0, 0, 0, 0, -1 }; + arrangement.ChordTemplates.Add(new ChordTemplate( + "Power", "Power", + [3, -1, -1, -1, -1, -1, 1], + [2, -1, -1, -1, -1, -1, 2])); + + // The seventh string is unused in this template; the string count must still survive. + arrangement.ChordTemplates.Add(new ChordTemplate( + "E", "E", + [-1, 2, 3, 1, -1, -1, -1], + [0, 2, 2, 1, 0, 0, -1])); + + var loaded = SaveAndLoad(arrangement, abridged); + + loaded.MetaData.Tuning.Strings.Should().Equal(0, 0, 0, 0, 0, 0, -1); + loaded.ChordTemplates[0].StringCount.Should().Be(7); + loaded.ChordTemplates[0].Frets.Should().Equal(2, -1, -1, -1, -1, -1, 2); + loaded.ChordTemplates[0].Fingers.Should().Equal(3, -1, -1, -1, -1, -1, 1); + loaded.ChordTemplates[1].StringCount.Should().Be(7); + loaded.ChordTemplates[1].Frets[6].Should().Be(-1); + } + + [Fact] + public static void LoadNormalizesSixStringTemplatesInSevenStringArrangement() + { + var arrangement = new InstrumentalArrangement(); + arrangement.MetaData.Tuning.Strings = new short[7]; + arrangement.ChordTemplates.Add(new ChordTemplate("E", "E", new sbyte[6], new sbyte[6])); + + var loaded = SaveAndLoad(arrangement, abridged: true); + + loaded.ChordTemplates[0].StringCount.Should().Be(7); + loaded.ChordTemplates[0].Frets[6].Should().Be(-1); + loaded.ChordTemplates[0].Fingers[6].Should().Be(-1); + } + + [Fact] + public static void SixStringXmlOutputHasNoExtendedAttributes() + { + var arrangement = new InstrumentalArrangement(); + arrangement.ChordTemplates.Add(new ChordTemplate("E", "E", new sbyte[6], new sbyte[6])); + var path = TempXmlPath(); + + try + { + arrangement.Save(path, writeAbridgedXml: false); + var xml = File.ReadAllText(path); + + xml.Should().Contain("fret5="); + xml.Should().NotContain("fret6"); + xml.Should().NotContain("finger6"); + xml.Should().NotContain("string6"); + + InstrumentalArrangement.Load(path).ChordTemplates[0].StringCount.Should().Be(6); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Fact] + public static void UnsupportedStringIndexIsRejected() + { + var path = TempXmlPath(); + File.WriteAllText( + path, + "" + + "" + + "" + + ""); + + try + { + Assert.Throws(() => InstrumentalArrangement.Load(path)); + } + finally + { + File.Delete(path); + } + } + } +} diff --git a/tests/Rocksmith2014.XML.Tests/StringLayoutTests.cs b/tests/Rocksmith2014.XML.Tests/StringLayoutTests.cs new file mode 100644 index 00000000..925f2a66 --- /dev/null +++ b/tests/Rocksmith2014.XML.Tests/StringLayoutTests.cs @@ -0,0 +1,65 @@ +using System; + +using FluentAssertions; + +using Xunit; + +namespace Rocksmith2014.XML.Tests +{ + public static class StringLayoutTests + { + [Fact] + public static void SixStringOrderIsStorageOrder() + { + StringLayout.LowToHigh(6).Should().Equal(0, 1, 2, 3, 4, 5); + } + + [Fact] + public static void SevenStringOrderStartsWithLowB() + { + StringLayout.LowToHigh(7).Should().Equal(6, 0, 1, 2, 3, 4, 5); + } + + [Fact] + public static void MusicalRankPlacesLowBFirst() + { + StringLayout.MusicalRank(6, 7).Should().Be(0); + StringLayout.MusicalRank(0, 7).Should().Be(1); + StringLayout.MusicalRank(5, 7).Should().Be(6); + StringLayout.MusicalRank(3, 6).Should().Be(3); + } + + [Fact] + public static void StandardMidiNotesMatchStandardTuning() + { + StringLayout.StandardMidiNote(0).Should().Be(40); // E2 + StringLayout.StandardMidiNote(4).Should().Be(59); // B3 + StringLayout.StandardMidiNote(5).Should().Be(64); // E4 + StringLayout.StandardMidiNote(6).Should().Be(35); // B1 + } + + [Fact] + public static void ToLowToHighReordersValues() + { + var frets = new sbyte[] { 0, 1, 2, 3, 4, 5, 9 }; + + StringLayout.ToLowToHigh(frets).Should().Equal(9, 0, 1, 2, 3, 4, 5); + } + + [Theory] + [InlineData(4)] + [InlineData(5)] + [InlineData(8)] + public static void UnsupportedStringCountsAreRejected(int stringCount) + { + StringLayout.IsSupportedStringCount(stringCount).Should().BeFalse(); + Assert.Throws(() => StringLayout.LowToHigh(stringCount)); + } + + [Fact] + public static void RankOutsideLayoutIsRejected() + { + Assert.Throws(() => StringLayout.MusicalRank(6, 6)); + } + } +} From 7eeef12dc1bcf2b66559cdc55da014127e132271 Mon Sep 17 00:00:00 2001 From: SomeeName <168603991+SomeeName@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:34:09 +0200 Subject: [PATCH 2/5] Fix string ordering when generating dynamcic difficulty --- src/Rocksmith2014.DD/EntityChooser.fs | 54 +++++++++++----- .../TemplateRequestApplier.fs | 26 ++++---- src/Rocksmith2014.DD/Utils.fs | 11 +++- .../Rocksmith2014.DD.Tests/GeneratorTests.fs | 62 +++++++++++++++++-- 4 files changed, 117 insertions(+), 36 deletions(-) diff --git a/src/Rocksmith2014.DD/EntityChooser.fs b/src/Rocksmith2014.DD/EntityChooser.fs index dcd80407..aad01e55 100644 --- a/src/Rocksmith2014.DD/EntityChooser.fs +++ b/src/Rocksmith2014.DD/EntityChooser.fs @@ -45,11 +45,18 @@ let private pruneChordNotes if notesToRemove < 0 then failwith $"Chord at time %O{float cn[0].Time / 1000.} has less chord notes than its chord template." - for i = cn.Count - notesToRemove to cn.Count - 1 do - if cn[i].IsLinkNext then - removedLinkNexts.Set(int cn[i].String, true) + // Remove the notes on the highest pitched strings (the seventh string is the lowest) + let removed = + cn + |> Seq.sortByDescending (fun n -> pitchRank n.String) + |> Seq.truncate notesToRemove + |> Seq.toArray + + for n in removed do + if n.IsLinkNext then + removedLinkNexts.Set(int n.String, true) - cn.RemoveRange(cn.Count - notesToRemove, notesToRemove) + cn.Remove(n) |> ignore for n in cn do pruneTechniques diffPercent removedLinkNexts n @@ -140,13 +147,14 @@ let private isFirstChordInHs (entities: XmlEntity list) (handShapes: HandShape l let private chordNotesFromTemplate (template: ChordTemplate) (chord: Chord) = let cn = ResizeArray() - for i = 0 to 5 do - if template.Frets[i] <> -1y then + for i = 0 to template.StringCount - 1 do + let fret = template.Frets[i] + if fret <> -1y then cn.Add( Note( Time = chord.Time, String = sbyte i, - Fret = template.Frets[i], + Fret = fret, LeftHand = template.Fingers[i], IsFretHandMute = chord.IsFretHandMute, IsPalmMute = chord.IsPalmMute, @@ -167,19 +175,33 @@ let private noteFromChord if chord.HasChordNotes then let cns = chord.ChordNotes - let noteIndex = if fromHighest then cns.Count - 1 else 0 + let rank (n: Note) = pitchRank n.String + + let selected = + if fromHighest then + cns |> Seq.maxBy rank + else + cns |> Seq.minBy rank + // Create the note from a chord note - for i = 0 to cns.Count - 1 do - if i <> noteIndex && cns[i].IsLinkNext then - removedLinkNexts.Set(int cns[i].String, true) + for cn in cns do + if not (Object.ReferenceEquals(cn, selected)) && cn.IsLinkNext then + removedLinkNexts.Set(int cn.String, true) - let note = Note(cns[noteIndex], LeftHand = -1y) + let note = Note(selected, LeftHand = -1y) pruneTechniques diffPercent removedLinkNexts note note else // Create the note from the chord template - let find = if fromHighest then Array.findIndexBack else Array.findIndex - let string = template.Frets |> find ((<>) -1y) + let usedStrings = + StringLayout.LowToHigh(template.StringCount) + |> Array.filter (fun stringIndex -> template.Frets[stringIndex] <> -1y) + + let string = + if fromHighest then + Array.last usedStrings + else + Array.head usedStrings Note( Time = chord.Time, @@ -200,8 +222,8 @@ let choose (diffPercent: float) (maxChordNotes: int) (entities: XmlEntity array) = // Strings from which a linknext note has been removed - let removedLinkNexts = BitArray(6) - let pendingLinkNexts: PendingLinkNexts = Array.zeroCreate 6 + let removedLinkNexts = BitArray(StringLayout.MaxStringCount) + let pendingLinkNexts: PendingLinkNexts = Array.zeroCreate StringLayout.MaxStringCount let currentNotesWithScore = Dictionary() let incrementCount division = diff --git a/src/Rocksmith2014.DD/TemplateRequestApplier.fs b/src/Rocksmith2014.DD/TemplateRequestApplier.fs index b333da86..ca7cc35f 100644 --- a/src/Rocksmith2014.DD/TemplateRequestApplier.fs +++ b/src/Rocksmith2014.DD/TemplateRequestApplier.fs @@ -9,23 +9,23 @@ type private NewData = { Fingers: sbyte array; Frets: sbyte array } let private globalTemplatesLock = obj () /// Returns new finger and fret arrays for the chord template with the given number of notes removed. -let private removeNotes (notesToRemove: int) (fromHighest: bool) (template: ChordTemplate) = +/// Uses musical order because string 6 is below the stock strings. +let private removeNotes (notesToRemove: int) (fromHighest: bool) (template: ChordTemplate) : NewData = let fingers = Array.copy template.Fingers let frets = Array.copy template.Frets - let startIndex, change = if fromHighest then 0, 1 else frets.Length - 1, -1 + let lowToHigh = StringLayout.LowToHigh(template.StringCount) + let removalOrder = if fromHighest then lowToHigh else Array.rev lowToHigh + let mutable leftToRemove = notesToRemove - let rec loop i leftToRemove = - if leftToRemove = 0 || i < 0 || i > 5 then - assert (leftToRemove = 0) - { Fingers = fingers; Frets = frets } - elif frets[i] <> -1y then - fingers[i] <- -1y - frets[i] <- -1y - loop (i + change) (leftToRemove - 1) - else - loop (i + change) leftToRemove + for stringIndex in removalOrder do + if leftToRemove > 0 && frets[stringIndex] <> -1y then + fingers[stringIndex] <- -1y + frets[stringIndex] <- -1y + leftToRemove <- leftToRemove - 1 - loop startIndex notesToRemove + assert (leftToRemove = 0) + + { Fingers = fingers; Frets = frets } /// Returns the ID of an existing chord template if an identical one already exists, otherwise adds a new template to the global template list. let private getChordId (templates: ResizeArray) (template: ChordTemplate) (newData: NewData) = diff --git a/src/Rocksmith2014.DD/Utils.fs b/src/Rocksmith2014.DD/Utils.fs index 3279a5fc..cacdf53a 100644 --- a/src/Rocksmith2014.DD/Utils.fs +++ b/src/Rocksmith2014.DD/Utils.fs @@ -12,6 +12,10 @@ let getNoteCount (template: ChordTemplate) = template.Frets |> Array.sumBy (fun fret -> Convert.ToInt32(fret >= 0y)) +/// Orders strings low to high; string 6 precedes string 0. +let pitchRank (stringIndex: sbyte) = + StringLayout.MusicalRank(int stringIndex, StringLayout.MaxStringCount) + let getAllowedChordNotes (diffPercent: float) (maxChordNotesInPhrase: int) = if maxChordNotesInPhrase = 0 then 0 @@ -21,9 +25,10 @@ let getAllowedChordNotes (diffPercent: float) (maxChordNotesInPhrase: int) = /// For double and triple stops on the highest strings, start adding notes from the highest string. let shouldStartFromHighestNote (totalNotes: int) (template: ChordTemplate) = - let fr = template.Frets - (totalNotes = 3 && fr[5] > -1y && fr[4] > -1y && fr[3] > -1y) - || (totalNotes = 2 && fr[5] > -1y && fr[4] > -1y) + let fr = StringLayout.ToLowToHigh(template.Frets) + let length = fr.Length + (totalNotes = 3 && length >= 3 && fr[length - 1] > -1y && fr[length - 2] > -1y && fr[length - 3] > -1y) + || (totalNotes = 2 && length >= 2 && fr[length - 1] > -1y && fr[length - 2] > -1y) let createTemplateRequest (originalId: int16) (noteCount: int) (totalNotes: int) (template: ChordTemplate) (target: RequestTarget) = let startFromHighestNote = shouldStartFromHighestNote totalNotes template diff --git a/tests/Rocksmith2014.DD.Tests/GeneratorTests.fs b/tests/Rocksmith2014.DD.Tests/GeneratorTests.fs index c68f06ce..788def0d 100644 --- a/tests/Rocksmith2014.DD.Tests/GeneratorTests.fs +++ b/tests/Rocksmith2014.DD.Tests/GeneratorTests.fs @@ -29,6 +29,13 @@ let arrangement (levels: Level seq) (chordTemplates: ChordTemplate seq) = MetaData = metadata ) +let private sevenStringArrangement (levels: Level seq) (chordTemplates: ChordTemplate seq) = + let arr = arrangement levels chordTemplates + arr.MetaData.Tuning.Strings <- Array.zeroCreate 7 + arr + +let private tenLevels = { config with LevelCountGeneration = LevelCountGeneration.Constant 10 } + [] let ddGeneratorTests = testList "DD Generator Tests" [ @@ -54,10 +61,9 @@ let ddGeneratorTests = let level = Level(0y, Chords = chords, HandShapes = handshapes) let template = ChordTemplate("G5", "G5", [| 1y; 3y; 4y; -1y; -1y; -1y; |], [| 3y; 5y; 5y; -1y; -1y; -1y |]) - let conf = { config with LevelCountGeneration = LevelCountGeneration.Constant 10 } let arr = arrangement [ level ] [ template ] - |> Generator.generateForArrangement conf + |> Generator.generateForArrangement tenLevels let highestLevel = arr.Levels[9] Expect.hasLength arr.Levels[0].Notes 1 "Note exists in lowest level" @@ -80,11 +86,59 @@ let ddGeneratorTests = let level = Level(0y, Chords = chords, HandShapes = handshapes) let template = ChordTemplate("", "", [| -1y; -1y; -1y; -1y; 1y; 1y; |], [| -1y; -1y; -1y; -1y; 5y; 5y |]) - let conf = { config with LevelCountGeneration = LevelCountGeneration.Constant 10 } let arr = arrangement [ level ] [ template ] - |> Generator.generateForArrangement conf + |> Generator.generateForArrangement tenLevels Expect.hasLength arr.Levels[0].Notes 1 "Note exists in lowest level" Expect.equal arr.Levels[0].Notes[0].String 5y "String is correct" + + testCase "Seven-string chord reduced to a note uses the low B as the lowest string" <| fun _ -> + let cn = ResizeArray(seq { + Note(String = 0y, Fret = 2y, Time = 5000, Sustain = 800) + Note(String = 1y, Fret = 2y, Time = 5000, Sustain = 800) + Note(String = 6y, Fret = 0y, Time = 5000, Sustain = 800) + }) + let chords = ResizeArray(seq { Chord(Time = 5000, ChordNotes = cn) }) + let handshapes = ResizeArray(seq { HandShape(0s, 5000, 5800) }) + let level = Level(0y, Chords = chords, HandShapes = handshapes) + let template = + ChordTemplate("B5", "B5", [| 1y; 1y; -1y; -1y; -1y; -1y; -1y |], [| 2y; 2y; -1y; -1y; -1y; -1y; 0y |]) + + let arr = + sevenStringArrangement [ level ] [ template ] + |> Generator.generateForArrangement tenLevels + + Expect.hasLength arr.Levels[0].Notes 1 "Note exists in lowest level" + Expect.equal arr.Levels[0].Notes[0].String 6y "The low B is the lowest string" + + testCase "Reduced seven-string templates keep the seventh string and match their chord notes" <| fun _ -> + let cn = ResizeArray(seq { + Note(String = 0y, Fret = 2y, Time = 5000, Sustain = 800) + Note(String = 1y, Fret = 2y, Time = 5000, Sustain = 800) + Note(String = 2y, Fret = 2y, Time = 5000, Sustain = 800) + Note(String = 6y, Fret = 0y, Time = 5000, Sustain = 800) + }) + let chords = ResizeArray(seq { Chord(Time = 5000, ChordNotes = cn) }) + let handshapes = ResizeArray(seq { HandShape(0s, 5000, 5800) }) + let level = Level(0y, Chords = chords, HandShapes = handshapes) + let template = + ChordTemplate("B5", "B5", [| 1y; 1y; 1y; -1y; -1y; -1y; -1y |], [| 2y; 2y; 2y; -1y; -1y; -1y; 0y |]) + + let arr = + sevenStringArrangement [ level ] [ template ] + |> Generator.generateForArrangement tenLevels + + Expect.all arr.ChordTemplates (fun t -> t.StringCount = 7) "All templates keep seven strings" + Expect.exists + arr.ChordTemplates + (fun t -> t.Frets[6] = 0y && t.Frets[0] = 2y && t.Frets[2] = -1y) + "A reduced template keeps the low B and removes the highest string" + + for lvl in arr.Levels do + for chord in lvl.Chords do + if chord.HasChordNotes then + let t = arr.ChordTemplates[int chord.ChordId] + for chordNote in chord.ChordNotes do + Expect.notEqual t.Frets[int chordNote.String] -1y "Chord note string is used by its template" ] From 5862d7992b5122eff9c8726e9944005f28d183d4 Mon Sep 17 00:00:00 2001 From: SomeeName <168603991+SomeeName@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:36:34 +0200 Subject: [PATCH 3/5] Handle extended strings in arrangement checks and fixes --- .../Checkers/InstrumentalChecker.fs | 10 ++++-- .../Improvers/BasicFixes.fs | 5 +-- .../Improvers/CustomEvents.fs | 10 ++++-- src/Rocksmith2014.XML.Processing/Utils.fs | 3 +- .../ArrangementImprover/BasicFixTests.fs | 32 +++++++++++++++++++ .../UtilsTests.fs | 25 +++++++++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/Rocksmith2014.XML.Processing/Checkers/InstrumentalChecker.fs b/src/Rocksmith2014.XML.Processing/Checkers/InstrumentalChecker.fs index e062bce2..fe532bea 100644 --- a/src/Rocksmith2014.XML.Processing/Checkers/InstrumentalChecker.fs +++ b/src/Rocksmith2014.XML.Processing/Checkers/InstrumentalChecker.fs @@ -312,13 +312,17 @@ let private chordHasBarreOverOpenStrings (chordTemplates: ResizeArray ResizeArray.tryItem (int chord.ChordId) |> Option.exists (fun ct -> + // The barre span is determined in pitch order (the seventh string is the lowest) + let fingers = StringLayout.ToLowToHigh(ct.Fingers) + let frets = StringLayout.ToLowToHigh(ct.Frets) + [ 0y..4y ] |> List.exists (fun finger -> - let low = Array.IndexOf(ct.Fingers, finger) - let high = Array.LastIndexOf(ct.Fingers, finger) + let low = Array.IndexOf(fingers, finger) + let high = Array.LastIndexOf(fingers, finger) low <> -1 && high > low && - ct.Frets.AsSpan(low, high - low).Contains(0y))) + frets.AsSpan(low, high - low).Contains(0y))) let private chordHasMutedString (chord: Chord) = not chord.IsFretHandMute diff --git a/src/Rocksmith2014.XML.Processing/Improvers/BasicFixes.fs b/src/Rocksmith2014.XML.Processing/Improvers/BasicFixes.fs index df2ac4c2..83393b8f 100644 --- a/src/Rocksmith2014.XML.Processing/Improvers/BasicFixes.fs +++ b/src/Rocksmith2014.XML.Processing/Improvers/BasicFixes.fs @@ -117,8 +117,9 @@ let removeMutedNotesFromChords (arrangement: InstrumentalArrangement) = |> Option.iter (fun template -> mutedNotes.ForEach(fun n -> let i = int n.String - template.Frets[i] <- -1y - template.Fingers[i] <- -1y) + if i >= 0 && i < template.StringCount then + template.Frets[i] <- -1y + template.Fingers[i] <- -1y) ) fixedChordTemplates.Add(chord.ChordId) |> ignore diff --git a/src/Rocksmith2014.XML.Processing/Improvers/CustomEvents.fs b/src/Rocksmith2014.XML.Processing/Improvers/CustomEvents.fs index 1afe128a..9af3f237 100644 --- a/src/Rocksmith2014.XML.Processing/Improvers/CustomEvents.fs +++ b/src/Rocksmith2014.XML.Processing/Improvers/CustomEvents.fs @@ -112,12 +112,16 @@ let improve (arrangement: InstrumentalArrangement) = let chordId = int16 arrangement.ChordTemplates.Count level.HandShapes.InsertByTime(HandShape(chordId, startTime, endTime)) - // Create a new chord template for the handshape - let soChordTemplate = ChordTemplate() + // Create a new chord template for the handshape (same string count as the original template) + let soChordTemplate = ChordTemplate(originalChordTemplate.StringCount) for note in notes do let s = int note.String + if s >= soChordTemplate.StringCount then + soChordTemplate.EnsureStringCount(s + 1) + soChordTemplate.Frets[s] <- note.SlideUnpitchTo - soChordTemplate.Fingers[s] <- originalChordTemplate.Fingers[s] + soChordTemplate.Fingers[s] <- + if s < originalChordTemplate.StringCount then originalChordTemplate.Fingers[s] else -1y arrangement.ChordTemplates.Add(soChordTemplate) diff --git a/src/Rocksmith2014.XML.Processing/Utils.fs b/src/Rocksmith2014.XML.Processing/Utils.fs index 4b85fca1..7daa5c20 100644 --- a/src/Rocksmith2014.XML.Processing/Utils.fs +++ b/src/Rocksmith2014.XML.Processing/Utils.fs @@ -110,7 +110,8 @@ let findPreviousChordUsingSameString else templates |> ResizeArray.tryItem (int chord.ChordId) - |> Option.exists (fun template -> template.Frets[int stringNum] > -1y)) + |> Option.exists (fun template -> + stringNum >= 0y && int stringNum < template.StringCount && template.Frets[int stringNum] > -1y)) |> Option.map (fun chord -> let fret = chord.ChordNotes diff --git a/tests/Rocksmith2014.XML.Processing.Tests/ArrangementImprover/BasicFixTests.fs b/tests/Rocksmith2014.XML.Processing.Tests/ArrangementImprover/BasicFixTests.fs index 8efad6a1..b22d2d6c 100644 --- a/tests/Rocksmith2014.XML.Processing.Tests/ArrangementImprover/BasicFixTests.fs +++ b/tests/Rocksmith2014.XML.Processing.Tests/ArrangementImprover/BasicFixTests.fs @@ -55,6 +55,19 @@ let basicFixTests = Expect.isTrue chords.[0].IsIgnore "First chord is ignored" Expect.isTrue chords.[1].IsIgnore "Second chord is ignored" + testCase "Ignore is added to chord with 23rd fret on the seventh string" <| fun _ -> + let noFingers = Array.replicate 7 -1y + let templates = ![ + ChordTemplate("", "", noFingers, [| 22y; -1y; -1y; -1y; -1y; -1y; 23y |]) + ] + let chords = ![ Chord(Time = 1000, ChordId = 0s) ] + let arr = InstrumentalArrangement(Levels = ![ Level(Chords = chords) ], ChordTemplates = templates) + arr.MetaData.Tuning.Strings <- Array.zeroCreate 7 + + BasicFixes.addIgnores arr + + Expect.isTrue chords.[0].IsIgnore "Chord is ignored" + testCase "Ignore is added to chord with 7th fret harmonic with sustain" <| fun _ -> let noFingers = [| -1y; -1y; -1y; -1y; -1y; -1y |] let templates = ![ @@ -185,6 +198,25 @@ let basicFixTests = Expect.equal templates[0].Frets[1] -1y "String was removed from first chord template" Expect.sequenceContainsOrder templates[1].Frets [| 0y; 0y; 0y; -1y; -1y; -1y; |] "Second chord template was not modified" + testCase "Muted seventh string is removed from non-muted chord and template" <| fun _ -> + let templates = ![ + ChordTemplate("", "", [| 1y; -1y; -1y; -1y; -1y; -1y; 3y |], [| 1y; -1y; -1y; -1y; -1y; -1y; 3y |]) + ] + let cn = ![ + Note(Time = 1000, String = 0y, Fret = 1y) + Note(Time = 1000, String = 6y, Fret = 3y, IsFretHandMute = true) + ] + let chords = ![ Chord(Time = 1000, ChordId = 0s, ChordNotes = cn) ] + let arr = InstrumentalArrangement(Levels = ![ Level(Chords = chords) ], ChordTemplates = templates) + arr.MetaData.Tuning.Strings <- Array.zeroCreate 7 + + BasicFixes.removeMutedNotesFromChords arr + + Expect.hasLength chords.[0].ChordNotes 1 "Muted seventh-string note was removed" + Expect.equal templates[0].Frets[6] -1y "Seventh string was removed from the template" + Expect.equal templates[0].Fingers[6] -1y "Seventh finger was removed from the template" + Expect.equal templates[0].Frets[0] 1y "Other strings are unchanged" + testCase "Redundant anchors are removed" <| fun _ -> let anchors = ![ Anchor(1y, 1000, 4y) diff --git a/tests/Rocksmith2014.XML.Processing.Tests/UtilsTests.fs b/tests/Rocksmith2014.XML.Processing.Tests/UtilsTests.fs index 7493b339..28fe283c 100644 --- a/tests/Rocksmith2014.XML.Processing.Tests/UtilsTests.fs +++ b/tests/Rocksmith2014.XML.Processing.Tests/UtilsTests.fs @@ -27,4 +27,29 @@ let tests = let time = Utils.getFirstNoteTime arr Expect.equal time (Some 5000) "First note time is correct" + + testCase "findPreviousChordUsingSameString finds a chord using the seventh string" <| fun _ -> + let templates = ![ + ChordTemplate("", "", Array.replicate 7 -1y, [| 2y; -1y; -1y; -1y; -1y; -1y; 4y |]) + ] + + let chords = ![ Chord(Time = 1000, ChordId = 0s) ] + let result = Utils.findPreviousChordUsingSameString templates chords 6y 2000 + + match result with + | Some (chord, fret) -> + Expect.equal chord.Time 1000 "Chord is found" + Expect.equal fret 4y "Template fret of the seventh string is used" + | None -> + failwith "Chord was not found" + + testCase "findPreviousChordUsingSameString does not fail on six-string templates" <| fun _ -> + let templates = ![ + ChordTemplate("", "", Array.replicate 6 -1y, [| 2y; 2y; -1y; -1y; -1y; -1y |]) + ] + + let chords = ![ Chord(Time = 1000, ChordId = 0s) ] + let result = Utils.findPreviousChordUsingSameString templates chords 6y 2000 + + Expect.isNone result "A six-string template does not use string 6" ] From ff1f622ee27b2f8a2e3dfc829f6bae21d44b2bf5 Mon Sep 17 00:00:00 2001 From: SomeeName <168603991+SomeeName@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:46:36 +0200 Subject: [PATCH 4/5] Get the string count from the tunings strings attribute and truncate chords with more than 6 strings --- src/Rocksmith2014.Conversion/XmlToSng.fs | 6 +- src/Rocksmith2014.XML/Tuning.cs | 25 +++- .../XmlObjectsToSngTests.fs | 10 ++ .../ChordTemplateTests.cs | 31 +++++ tests/Rocksmith2014.XML.Tests/TuningTests.cs | 111 ++++++++++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/Rocksmith2014.XML.Tests/TuningTests.cs diff --git a/src/Rocksmith2014.Conversion/XmlToSng.fs b/src/Rocksmith2014.Conversion/XmlToSng.fs index a619ad6e..ec979cd3 100644 --- a/src/Rocksmith2014.Conversion/XmlToSng.fs +++ b/src/Rocksmith2014.Conversion/XmlToSng.fs @@ -90,8 +90,10 @@ let convertChord (xml: XML.InstrumentalArrangement) (xmlChord: XML.ChordTemplate ChordMask.None { Mask = mask - Frets = Array.copy xmlChord.Frets - Fingers = Array.copy xmlChord.Fingers + // The SNG chord record is fixed six-wide (Chord.Read reads 6); a seventh + // template column must not reach it. + Frets = Array.truncate 6 xmlChord.Frets + Fingers = Array.truncate 6 xmlChord.Fingers Notes = Midi.mapToMidiNotes xml.MetaData xmlChord.Frets Name = xmlChord.Name } diff --git a/src/Rocksmith2014.XML/Tuning.cs b/src/Rocksmith2014.XML/Tuning.cs index dff50b17..b8e5f2c5 100644 --- a/src/Rocksmith2014.XML/Tuning.cs +++ b/src/Rocksmith2014.XML/Tuning.cs @@ -52,11 +52,28 @@ void IXmlSerializable.ReadXml(XmlReader reader) Strings[i] = short.Parse(value, NumberFormatInfo.InvariantInfo); } + // Charter write string6..string8 as copies of string5 on every + // arrangement, so the seventh string exists only when the count matches. + // Files without a count fall back to the presence of string6. var seventhString = reader.GetAttribute("string6"); - if (seventhString is not null) + var declaredCount = reader.GetAttribute("strings"); + var stringCount = declaredCount is null + ? (seventhString is null ? StringLayout.StockStringCount : StringLayout.MaxStringCount) + : int.Parse(declaredCount, NumberFormatInfo.InvariantInfo); + + if (stringCount > StringLayout.MaxStringCount) + { + throw new NotSupportedException( + $"Tunings with more than {StringLayout.MaxStringCount} strings are not supported (found {stringCount})."); + } + + if (stringCount == StringLayout.MaxStringCount) { + if (seventhString is null) + throw new XmlException("Missing required tuning attribute 'string6' for a seven-string arrangement."); + var strings = Strings; - Array.Resize(ref strings, 7); + Array.Resize(ref strings, StringLayout.MaxStringCount); Strings = strings; Strings[6] = short.Parse(seventhString, NumberFormatInfo.InvariantInfo); } @@ -68,6 +85,10 @@ void IXmlSerializable.WriteXml(XmlWriter writer) { for (var i = 0; i < Strings.Length; i++) writer.WriteAttributeString($"string{i}", Strings[i].ToString(NumberFormatInfo.InvariantInfo)); + + // Only a seven-string declares its count, so six-string files stay unchanged. + if (Strings.Length == StringLayout.MaxStringCount) + writer.WriteAttributeString("strings", Strings.Length.ToString(NumberFormatInfo.InvariantInfo)); } #endregion diff --git a/tests/Rocksmith2014.Conversion.Tests/XmlObjectsToSngTests.fs b/tests/Rocksmith2014.Conversion.Tests/XmlObjectsToSngTests.fs index c314fdfe..73fd2cd1 100644 --- a/tests/Rocksmith2014.Conversion.Tests/XmlObjectsToSngTests.fs +++ b/tests/Rocksmith2014.Conversion.Tests/XmlObjectsToSngTests.fs @@ -187,6 +187,16 @@ let xmlToSngConversionTests = Expect.equal sng.Mask SNG.ChordMask.Nop "Nop is set" + testCase "Chord Template (seven-string stays six wide in SNG)" <| fun _ -> + let ct = ChordTemplate(7) + ct.Frets[6] <- 3y + let testArr = createTestArr () + + let sng = XmlToSng.convertChord testArr ct + + Expect.equal sng.Frets.Length 6 "SNG frets are six wide" + Expect.equal sng.Fingers.Length 6 "SNG fingers are six wide" + testCase "Bend Value" <| fun _ -> let bv = BendValue(456465, 99.f) diff --git a/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs b/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs index 21c7a859..200cc2de 100644 --- a/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs +++ b/tests/Rocksmith2014.XML.Tests/ChordTemplateTests.cs @@ -132,6 +132,37 @@ public static void SixStringXmlOutputHasNoExtendedAttributes() } } + [Fact] + public static void CharterSixStringFillerStaysSixStringThroughLoadAndSave() + { + // Charter writes string6..string8 as copies of string5 and declares strings="6". + var xml = File.ReadAllText("instrumental.xml").Replace( + "", + ""); + xml.Should().Contain("string6=\"0\"", "the sample's tuning element was replaced"); + var path = TempXmlPath(); + File.WriteAllText(path, xml); + + try + { + var loaded = InstrumentalArrangement.Load(path); + + loaded.MetaData.Tuning.Strings.Should().HaveCount(6); + loaded.ChordTemplates.Should().OnlyContain(ct => ct.StringCount == 6); + + loaded.Save(path, writeAbridgedXml: false); + var saved = File.ReadAllText(path); + + saved.Should().NotContain("string6").And.NotContain("strings="); + saved.Should().NotContain("fret6").And.NotContain("finger6"); + } + finally + { + File.Delete(path); + } + } + [Fact] public static void UnsupportedStringIndexIsRejected() { diff --git a/tests/Rocksmith2014.XML.Tests/TuningTests.cs b/tests/Rocksmith2014.XML.Tests/TuningTests.cs new file mode 100644 index 00000000..1cd02fc1 --- /dev/null +++ b/tests/Rocksmith2014.XML.Tests/TuningTests.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.Serialization; + +using FluentAssertions; + +using Xunit; + +namespace Rocksmith2014.XML.Tests +{ + public static class TuningTests + { + private const string StockStrings = + "string0=\"0\" string1=\"0\" string2=\"0\" string3=\"0\" string4=\"0\" string5=\"0\""; + + private static Tuning Read(string xml) + { + var tuning = new Tuning(); + using var reader = XmlReader.Create(new StringReader(xml)); + reader.MoveToContent(); + ((IXmlSerializable)tuning).ReadXml(reader); + return tuning; + } + + private static string Write(Tuning tuning) + { + var sb = new StringBuilder(); + using (var writer = XmlWriter.Create(sb, new XmlWriterSettings { OmitXmlDeclaration = true })) + { + writer.WriteStartElement("tuning"); + ((IXmlSerializable)tuning).WriteXml(writer); + writer.WriteEndElement(); + } + return sb.ToString(); + } + + [Fact] + public static void FillerString6WithSixDeclaredStringsStaysSixString() + { + // What Charter writes for every six-string arrangement. + var tuning = Read(""); + + tuning.Strings.Should().HaveCount(6); + tuning.Strings[0].Should().Be(-2); + Write(tuning).Should().NotContain("string6").And.NotContain("strings="); + } + + [Fact] + public static void FillerString6WithFourDeclaredStringsStaysSixString() + { + // What Charter writes for a bass arrangement. + var tuning = Read($""); + + tuning.Strings.Should().HaveCount(6); + } + + [Fact] + public static void DeclaredSevenStringsReadsTheLowB() + { + var tuning = Read($""); + + tuning.Strings.Should().HaveCount(7); + tuning.Strings[6].Should().Be(-1); + } + + [Fact] + public static void String6WithoutDeclaredCountIsSevenString() + { + var tuning = Read($""); + + tuning.Strings.Should().HaveCount(7); + } + + [Fact] + public static void DeclaredSevenStringsWithoutString6Throws() + { + Action read = () => Read($""); + + read.Should().Throw(); + } + + [Fact] + public static void MoreThanSevenDeclaredStringsThrows() + { + Action read = () => Read($""); + + read.Should().Throw(); + } + + [Fact] + public static void SixStringWritesOnlyTheStockAttributes() + { + Write(new Tuning()).Should().Be($""); + } + + [Fact] + public static void SevenStringRoundTrips() + { + var tuning = Read($""); + + var written = Write(tuning); + var reread = Read(written); + + written.Should().Contain("string6=\"-2\"").And.Contain("strings=\"7\""); + reread.Strings.Should().Equal(tuning.Strings); + } + } +} From c1f7c63b2dde77e04e5d39def8ec3bd8fd6efce2 Mon Sep 17 00:00:00 2001 From: SomeeName <168603991+SomeeName@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:51:25 +0200 Subject: [PATCH 5/5] Support seven string tunings in tuning names --- src/Rocksmith2014.DLCProject/Utils.fs | 8 +++++--- tests/Rocksmith2014.DLCProject.Tests/UtilsTests.fs | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Rocksmith2014.DLCProject/Utils.fs b/src/Rocksmith2014.DLCProject/Utils.fs index 84610052..0ce3053a 100644 --- a/src/Rocksmith2014.DLCProject/Utils.fs +++ b/src/Rocksmith2014.DLCProject/Utils.fs @@ -2,6 +2,7 @@ module Rocksmith2014.DLCProject.Utils open System open FSharp.Extensions +open Rocksmith2014.XML /// Converts a tuning pitch into cents. let tuningPitchToCents (pitch: float) = @@ -12,7 +13,8 @@ let centsToTuningPitch (cents: float) = Math.Round(440. * Math.Pow(Math.Pow(2., 1. / 1200.), cents), 2) let private roots = [| "E"; "F"; "F#"; "G"; "Ab"; "A"; "Bb"; "B"; "C"; "C#"; "D"; "Eb" |] -let private standardTuningOffsets = [| 0; 5; 10; 3; 7; 0 |] +// Semitones above E of each open string (the seventh string is the low B) +let private standardTuningOffsets = [| 0; 5; 10; 3; 7; 0; 7 |] let private isDoubleDropTuning (first: int16) (strings: int16 array) = first = strings[1] - 2s && first = strings[5] @@ -74,8 +76,8 @@ let getTuningName (tuning: int16 array) : TuningName = | [| -2s; -2s; 0s; 0s; 0s; -2s |] -> TranslatableTuning("OpenTuning", [| "G" |]) | [| 0s; 2s; 2s; 1s; 0s; 0s |] -> TranslatableTuning("OpenTuning", [| "E" |]) | _ -> - tuning - |> Array.mapi (getStringNoteName true) + StringLayout.LowToHigh(tuning.Length) + |> Array.map (fun i -> getStringNoteName true i tuning[i]) |> String.Concat |> CustomTuning diff --git a/tests/Rocksmith2014.DLCProject.Tests/UtilsTests.fs b/tests/Rocksmith2014.DLCProject.Tests/UtilsTests.fs index 02f6cc49..86e0ef83 100644 --- a/tests/Rocksmith2014.DLCProject.Tests/UtilsTests.fs +++ b/tests/Rocksmith2014.DLCProject.Tests/UtilsTests.fs @@ -32,4 +32,13 @@ let utilsTests = Expect.equal name expected "Incorrect tuning name" } + + test "Custom tuning name of a seven-string tuning starts from the low B string" { + let dropA = [| 0s; 0s; 0s; 0s; 0s; 0s; -2s |] + + let name = Utils.getTuningName dropA + let expected = Utils.CustomTuning("AEADGBE") + + Expect.equal name expected "Incorrect tuning name" + } ]