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"
+ }
]