diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs
index 1c66f4c0..4261f009 100644
--- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs
+++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs
@@ -1,4 +1,6 @@
using System;
+using System.IO;
+using System.Text.RegularExpressions;
using NUnit.Framework;
using Nova.Simulation.CommandsV1;
using Nova.Simulation.Replays;
@@ -78,20 +80,39 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct()
"D-106 rules must not match the legacy empty rules stub");
}
+ ///
+ /// The failure text of every rules-revision tripwire (#138, D-120).
+ ///
+ private const string AppendRevisionHint =
+ "A hashed rules constant changed under an existing rules revision. Append the next " +
+ "RulesRevision with its own frozen literal row (MatchFingerprint, D-120) — do not " +
+ "re-pin a golden hash and do not edit a frozen row.";
+
[Test]
public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable()
{
ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1);
ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2);
- // NOT actually frozen — see issue #138. ComputeRulesHash64 writes
- // TODAY's constants into every revision, so raising
- // HqBaseCapacityAE (#131) moved the revision-1 hash too. Re-pinning
- // here restores green; it does NOT restore the guarantee the name
- // claims. Do not read this assertion as proof of anything until #138
- // is decided.
- Assert.That(revisionOne, Is.EqualTo(0x1163158B8146B168UL), "revision 1 canonical stream is frozen");
- Assert.That(revisionTwo, Is.EqualTo(0x259E6977B751109BUL), "revision 2 canonical stream is frozen");
+ // Original pins (#138, D-120): revision 1 as introduced in e1970a9
+ // (HQ 2,000 / storage 2,000 / decay 25 % every 10 ticks), revision 2
+ // as introduced in 8512e9f (plus repair 10 / low-power repair 5).
+ // Both rows are literals now, so no later constant edit can move
+ // them; a red assertion here means a frozen row was edited.
+ Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen");
+ Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen");
+ }
+
+ [Test]
+ public void RulesRevisionThree_FrozenLiteral_IsTheCurrentStream()
+ {
+ // Revision 3 as of e4faa75 (#131, HQ 3,000). Its pre-#131 identity
+ // 0x05CCA8475789AD4A is the accepted D-120 gap.
+ Assert.That(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3),
+ Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen");
+ Assert.Throws(() => MatchFingerprint.ComputeRulesHash64(0));
+ Assert.Throws(() =>
+ MatchFingerprint.ComputeRulesHash64((ushort)(MatchFingerprint.CurrentRulesRevision + 1)));
}
[Test]
@@ -101,12 +122,79 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair()
ulong current = MatchFingerprint.ComputeCurrentRulesHash64();
Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3));
- Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3)));
+ Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3)),
+ AppendRevisionHint);
Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen");
Assert.That(current, Is.Not.EqualTo(revisionTwo),
"D-104 placement and repair behavior must not share revision 2's rules identity");
}
+ [Test]
+ public void CurrentLiveRules_MatchTheFrozenCurrentRevision()
+ {
+ // The tripwire (#138, D-120): the live constants must equal the
+ // frozen literal row of the current revision, field by field. The
+ // message names the moved constant instead of two opaque hashes.
+ string[] drift = MatchFingerprint.DescribeCurrentRulesDrift();
+
+ Assert.That(drift, Is.Empty, AppendRevisionHint + "\n" + string.Join("\n", drift));
+ Assert.That(MatchFingerprint.ComputeCurrentRulesHash64(),
+ Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.CurrentRulesRevision)),
+ AppendRevisionHint);
+ }
+
+ [Test]
+ public void FrozenRevisions_ReadNoLiveConstants()
+ {
+ // Source-text guard (the NoFloatInSimulationTests pattern): the
+ // frozen-row region of MatchFingerprint.cs may hold literal values
+ // only. A row that reads EconomySystem/ConstructionSystem/... would
+ // compile, hash identically today and silently unfreeze again with
+ // the next balance change — exactly the #138 defect.
+ string source = File.ReadAllText(ResolveMatchFingerprintSource());
+ const string OpenMarker = "// ";
+ const string CloseMarker = "// ";
+ int open = source.IndexOf(OpenMarker, StringComparison.Ordinal);
+ int close = source.IndexOf(CloseMarker, StringComparison.Ordinal);
+ Assert.That(open, Is.GreaterThanOrEqualTo(0), "frozen-row region start marker missing");
+ Assert.That(close, Is.GreaterThan(open), "frozen-row region end marker missing");
+ Assert.That(source.IndexOf(OpenMarker, open + 1, StringComparison.Ordinal), Is.EqualTo(-1),
+ "exactly one frozen-row region expected");
+
+ // Comments go first: they may name live constants freely.
+ string region = Regex.Replace(source.Substring(open, close - open), @"//[^\n]*", string.Empty);
+
+ // Every row entry is RulesField.IntXX(, "", );
+ // a SimFixed field is frozen as its raw value (decimal or hex).
+ int entries = Regex.Matches(region, @"RulesField\s*\.\s*Int(32|64)\s*\(").Count;
+ int literalEntries = Regex.Matches(region,
+ @"RulesField\s*\.\s*Int(32|64)\s*\(\s*\d+u?\s*,\s*""[^""]*""\s*,\s*-?(0[xX][0-9A-Fa-f]+|\d+)[Ll]?\s*\)").Count;
+ Assert.That(entries, Is.GreaterThanOrEqualTo(4 + 6 + 13), "expected the rows of revisions 1-3");
+ Assert.That(literalEntries, Is.EqualTo(entries),
+ "every frozen row value must be an integer literal, never a live constant");
+
+ // Strings blanked: no member access other than the RulesField factories.
+ string code = Regex.Replace(region, @"""[^""]*""", "\"\"");
+ MatchCollection access = Regex.Matches(code, @"\b(?!RulesField\b)[A-Za-z_]\w*\s*\.\s*[A-Za-z_]\w*");
+ var offenders = new string[access.Count];
+ for (int i = 0; i < access.Count; i++) offenders[i] = access[i].Value;
+ Assert.That(offenders, Is.Empty,
+ "the frozen-row region must not reference live constants: " + string.Join(", ", offenders));
+ }
+
+ ///
+ /// THE ONE LANE-SPECIFIC METHOD. The .NET mirror walks up from the test
+ /// binary; here the anchor is Application.dataPath (the
+ /// NoFloatInSimulationTests pattern).
+ ///
+ private static string ResolveMatchFingerprintSource()
+ {
+ string scripts = Path.Combine(UnityEngine.Application.dataPath, "_Project", "Scripts");
+ Assert.That(Directory.Exists(scripts), Is.True,
+ $"could not locate {scripts} — Application.dataPath is {UnityEngine.Application.dataPath}");
+ return Path.Combine(scripts, "Simulation", "Replays", "MatchFingerprint.cs");
+ }
+
[Test]
public void ComputeHash_AndEquality_AreSensitiveToEveryField()
{
diff --git a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs
index bdc97a68..eafcbcb7 100644
--- a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs
+++ b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Text;
using Nova.Core;
using Nova.Simulation.CommandsV1;
@@ -112,8 +113,8 @@ public sealed class MatchFingerprint : IEquatable
public const ushort RulesRevisionV1 = 1;
///
- /// Current deterministic rules revision; adds the Sprint-16.6 C4 low-power
- /// radar and repair behavior to revision 1.
+ /// Sprint-16.6 C4 low-power radar and repair behavior layered onto
+ /// revision 1. Historical: frozen as a literal row (#138, D-120).
///
public const ushort RulesRevisionV2 = 2;
@@ -123,7 +124,12 @@ public sealed class MatchFingerprint : IEquatable
///
public const ushort RulesRevisionV3 = 3;
- /// The rules revision emitted by current hosts.
+ ///
+ /// The rules revision emitted by current hosts. Revisions are append
+ /// only (#138, D-120): changing a hashed rules constant means
+ /// appending the next revision with its own frozen literal row, never
+ /// editing an existing row or re-pinning its golden hash.
+ ///
public const ushort CurrentRulesRevision = RulesRevisionV3;
/// Parser bound for one identifier string; checked before allocation.
@@ -264,74 +270,228 @@ public static ulong ComputeEmptyContentStubHash(MatchContentStub stub)
}
///
- /// Canonical rules identity for one supported simulation revision.
- /// This compatibility entry point exists so replay and relay tests can
- /// represent prior rules exactly; hosts must use
- /// . Unknown revisions are
- /// rejected rather than guessed.
+ /// Canonical rules identity of one supported simulation revision, read
+ /// exclusively from that revision's frozen literal row (#138, D-120).
+ /// A row is written once, when its revision is appended, and never
+ /// edited, so a later change to a live rules constant cannot move a
+ /// historical identity: replay and relay tests represent prior rules
+ /// exactly. Hosts must use .
+ /// Unknown revisions are rejected rather than guessed.
+ ///
+ /// Known, accepted gap (D-120): #131 raised HqBaseCapacityAE from
+ /// 2,000 to 3,000 without a revision bump, so builds from 2026-08-10
+ /// to 2026-08-31 emitted revision 3 as 0x05CCA8475789AD4A. The frozen
+ /// revision-3 row carries the post-#131 values (0xD1B683837D12FCEF);
+ /// the earlier revision-3 identity is not representable.
+ ///
///
public static ulong ComputeRulesHash64(ushort rulesRevision)
{
- if (rulesRevision != RulesRevisionV1
- && rulesRevision != RulesRevisionV2
- && rulesRevision != RulesRevisionV3)
+ return HashRulesRow(rulesRevision, GetFrozenRulesRow(rulesRevision));
+ }
+
+ ///
+ /// Canonical rules identity for the current simulation: the layout of
+ /// , written from the LIVE rules
+ /// constants. Revision 3 retains D-106 and Sprint-16.6 C4 from
+ /// revisions 1/2, then binds the D-104 footprint, influence,
+ /// field-spacing, repair-price and single-winner behavior that can
+ /// diverge without changing snapshot bytes or definition rows. Old/new
+ /// peers and replays therefore fail the exact-fingerprint gate before
+ /// executing tick 1. This is the only rules-hash path that reads live
+ /// constants (#138, D-120);
+ /// is the tripwire that keeps it equal to the frozen row of the
+ /// current revision.
+ ///
+ public static ulong ComputeCurrentRulesHash64()
+ {
+ return HashRulesRow(CurrentRulesRevision, BuildLiveCurrentRulesRow());
+ }
+
+ ///
+ /// Tripwire for the append-only revision rule (#138, D-120): compares
+ /// the live rules constants field by field (tag, width, value) with
+ /// the frozen literal row of and
+ /// returns one line per difference; an empty array means they agree.
+ /// A non-empty result means a hashed rules constant changed under an
+ /// existing revision number. The remedy is to append the next rules
+ /// revision with its own literal row, never to edit a frozen row or
+ /// re-pin a golden hash.
+ ///
+ public static string[] DescribeCurrentRulesDrift()
+ {
+ RulesField[] frozen = GetFrozenRulesRow(CurrentRulesRevision);
+ RulesField[] live = BuildLiveCurrentRulesRow();
+ var drift = new List();
+ if (frozen.Length != live.Length)
{
- throw new ArgumentOutOfRangeException(nameof(rulesRevision), rulesRevision, "Unknown rules revision.");
+ drift.Add($"revision {CurrentRulesRevision}: the frozen row has {frozen.Length} fields, "
+ + $"the live layout has {live.Length}");
}
+ int shared = Math.Min(frozen.Length, live.Length);
+ for (int i = 0; i < shared; i++)
+ {
+ RulesField frozenField = frozen[i];
+ RulesField liveField = live[i];
+ if (frozenField.Tag != liveField.Tag
+ || frozenField.IsInt64 != liveField.IsInt64
+ || frozenField.Value != liveField.Value)
+ {
+ drift.Add($"revision {CurrentRulesRevision} field {frozenField.Tag} ({liveField.Name}): "
+ + $"frozen {frozenField.Describe()}, live {liveField.Describe()}");
+ }
+ }
+ return drift.ToArray();
+ }
+
+ ///
+ /// Writes one rules-identity stream. The byte layout is unchanged from
+ /// the pre-#138 writer: stub tag 1, the u32 field count (field 1 plus
+ /// the row), field 1 = the revision as u16, then every row field as
+ /// its tag followed by an i64 or i32 value, in row order.
+ ///
+ private static ulong HashRulesRow(ushort rulesRevision, RulesField[] row)
+ {
var hash = SimHashWriter.ForDefinitions();
hash.WriteFieldTag((uint)MatchContentStub.Rules);
- hash.WriteUInt32(rulesRevision == RulesRevisionV1
- ? 5u
- : rulesRevision == RulesRevisionV2 ? 7u : 14u); // ordered rule fields below
+ hash.WriteUInt32((uint)row.Length + 1u); // field 1 (the revision) plus the ordered rule fields
hash.WriteFieldTag(1);
hash.WriteUInt16(rulesRevision);
- hash.WriteFieldTag(2);
- hash.WriteInt64(EconomySystem.HqBaseCapacityAE);
- hash.WriteFieldTag(3);
- hash.WriteInt64(EconomySystem.StorageCapacityBonusAE);
- hash.WriteFieldTag(4);
- hash.WriteInt32(EconomySystem.ExcessDecayPercent);
- hash.WriteFieldTag(5);
- hash.WriteInt32(EconomySystem.ExcessDecayIntervalTicks);
- if (rulesRevision >= RulesRevisionV2)
+ for (int i = 0; i < row.Length; i++)
{
- hash.WriteFieldTag(6);
- hash.WriteInt32(ConstructionSystem.RepairRateHpPerTick);
- hash.WriteFieldTag(7);
- hash.WriteInt32(ConstructionSystem.LowPowerRepairRateHpPerTick);
+ hash.WriteFieldTag(row[i].Tag);
+ if (row[i].IsInt64)
+ {
+ hash.WriteInt64(row[i].Value);
+ }
+ else
+ {
+ hash.WriteInt32((int)row[i].Value);
+ }
}
- if (rulesRevision >= RulesRevisionV3)
+ return hash.Digest();
+ }
+
+ ///
+ /// The layout of read from the live
+ /// rules constants — the only place the rules hash touches them
+ /// (#138, D-120). Appending a revision extends this row with the live
+ /// reads of the new fields in the same order as the new frozen row.
+ ///
+ private static RulesField[] BuildLiveCurrentRulesRow()
+ {
+ return new[]
{
- hash.WriteFieldTag(8);
- hash.WriteInt32(SimDefinitions.BuildingFootprintCells);
- hash.WriteFieldTag(9);
- hash.WriteInt32(ConstructionSystem.RepairCostPercent);
- hash.WriteFieldTag(10);
- hash.WriteInt32(ConstructionSystem.BuildInfluenceRadiusCells);
- hash.WriteFieldTag(11);
- hash.WriteInt32(ConstructionSystem.MinimumBuildingDistanceCells);
- hash.WriteFieldTag(12);
- hash.WriteInt32(ConstructionSystem.RefineryMinimumFieldDistanceCells);
- hash.WriteFieldTag(13);
- hash.WriteInt32(ConstructionSystem.RefineryMaximumFieldDistanceCells);
- hash.WriteFieldTag(14);
- hash.WriteInt32(ConstructionSystem.MinimumNonRefineryFieldDistanceCells);
+ RulesField.Int64(2, nameof(EconomySystem.HqBaseCapacityAE), EconomySystem.HqBaseCapacityAE),
+ RulesField.Int64(3, nameof(EconomySystem.StorageCapacityBonusAE), EconomySystem.StorageCapacityBonusAE),
+ RulesField.Int32(4, nameof(EconomySystem.ExcessDecayPercent), EconomySystem.ExcessDecayPercent),
+ RulesField.Int32(5, nameof(EconomySystem.ExcessDecayIntervalTicks), EconomySystem.ExcessDecayIntervalTicks),
+ RulesField.Int32(6, nameof(ConstructionSystem.RepairRateHpPerTick), ConstructionSystem.RepairRateHpPerTick),
+ RulesField.Int32(7, nameof(ConstructionSystem.LowPowerRepairRateHpPerTick), ConstructionSystem.LowPowerRepairRateHpPerTick),
+ RulesField.Int32(8, nameof(SimDefinitions.BuildingFootprintCells), SimDefinitions.BuildingFootprintCells),
+ RulesField.Int32(9, nameof(ConstructionSystem.RepairCostPercent), ConstructionSystem.RepairCostPercent),
+ RulesField.Int32(10, nameof(ConstructionSystem.BuildInfluenceRadiusCells), ConstructionSystem.BuildInfluenceRadiusCells),
+ RulesField.Int32(11, nameof(ConstructionSystem.MinimumBuildingDistanceCells), ConstructionSystem.MinimumBuildingDistanceCells),
+ RulesField.Int32(12, nameof(ConstructionSystem.RefineryMinimumFieldDistanceCells), ConstructionSystem.RefineryMinimumFieldDistanceCells),
+ RulesField.Int32(13, nameof(ConstructionSystem.RefineryMaximumFieldDistanceCells), ConstructionSystem.RefineryMaximumFieldDistanceCells),
+ RulesField.Int32(14, nameof(ConstructionSystem.MinimumNonRefineryFieldDistanceCells), ConstructionSystem.MinimumNonRefineryFieldDistanceCells),
+ };
+ }
+
+ //
+ // Frozen literal rows, one per rules revision (#138, D-120). APPEND
+ // ONLY: a row is written once, when its revision is introduced, and is
+ // never edited afterwards. Every value is a literal, never a reference
+ // to a live constant; the FrozenRevisions_ReadNoLiveConstants tests
+ // check this region as source text. Field 1 (the revision number) is
+ // written by HashRulesRow and is not part of a row.
+ //
+ // To append revision N: declare RulesRevisionVN; add FrozenRulesRowVN
+ // = the previous row with today's values as literals plus the new
+ // fields; add its case to GetFrozenRulesRow; extend
+ // BuildLiveCurrentRulesRow with the live reads of the new fields; move
+ // CurrentRulesRevision; pin the new golden hash next to the old ones.
+
+ /// Revision 1 (e1970a9, D-106): HQ/storage capacity and excess decay.
+ private static readonly RulesField[] FrozenRulesRowV1 =
+ {
+ RulesField.Int64(2, "HqBaseCapacityAE", 2000L),
+ RulesField.Int64(3, "StorageCapacityBonusAE", 2000L),
+ RulesField.Int32(4, "ExcessDecayPercent", 25),
+ RulesField.Int32(5, "ExcessDecayIntervalTicks", 10),
+ };
+
+ /// Revision 2 (8512e9f, Sprint-16.6 C4): revision 1 plus repair rates.
+ private static readonly RulesField[] FrozenRulesRowV2 =
+ {
+ RulesField.Int64(2, "HqBaseCapacityAE", 2000L),
+ RulesField.Int64(3, "StorageCapacityBonusAE", 2000L),
+ RulesField.Int32(4, "ExcessDecayPercent", 25),
+ RulesField.Int32(5, "ExcessDecayIntervalTicks", 10),
+ RulesField.Int32(6, "RepairRateHpPerTick", 10),
+ RulesField.Int32(7, "LowPowerRepairRateHpPerTick", 5),
+ };
+
+ ///
+ /// Revision 3 (4cdcf07, D-104), values as of e4faa75 (#131: HQ 3,000).
+ /// The pre-#131 identity of revision 3 is the accepted D-120 gap.
+ ///
+ private static readonly RulesField[] FrozenRulesRowV3 =
+ {
+ RulesField.Int64(2, "HqBaseCapacityAE", 3000L),
+ RulesField.Int64(3, "StorageCapacityBonusAE", 2000L),
+ RulesField.Int32(4, "ExcessDecayPercent", 25),
+ RulesField.Int32(5, "ExcessDecayIntervalTicks", 10),
+ RulesField.Int32(6, "RepairRateHpPerTick", 10),
+ RulesField.Int32(7, "LowPowerRepairRateHpPerTick", 5),
+ RulesField.Int32(8, "BuildingFootprintCells", 3),
+ RulesField.Int32(9, "RepairCostPercent", 30),
+ RulesField.Int32(10, "BuildInfluenceRadiusCells", 8),
+ RulesField.Int32(11, "MinimumBuildingDistanceCells", 2),
+ RulesField.Int32(12, "RefineryMinimumFieldDistanceCells", 1),
+ RulesField.Int32(13, "RefineryMaximumFieldDistanceCells", 3),
+ RulesField.Int32(14, "MinimumNonRefineryFieldDistanceCells", 2),
+ };
+
+ private static RulesField[] GetFrozenRulesRow(ushort rulesRevision)
+ {
+ switch (rulesRevision)
+ {
+ case RulesRevisionV1: return FrozenRulesRowV1;
+ case RulesRevisionV2: return FrozenRulesRowV2;
+ case RulesRevisionV3: return FrozenRulesRowV3;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(rulesRevision), rulesRevision, "Unknown rules revision.");
}
- return hash.Digest();
}
+ //
///
- /// Canonical rules identity for the current simulation. Revision 3
- /// retains D-106 and Sprint-16.6 C4 from revisions 1/2, then binds the
- /// D-104 footprint, influence, field-spacing, repair-price and
- /// single-winner behavior that can diverge without changing snapshot
- /// bytes or definition rows. Old/new peers and replays therefore fail
- /// the exact-fingerprint gate before executing tick 1.
+ /// One tagged, typed value of a rules-identity row (#138, D-120). The
+ /// name is diagnostic only and never hashed or compared, so renaming
+ /// a constant does not trip the drift check.
///
- public static ulong ComputeCurrentRulesHash64()
+ private readonly struct RulesField
{
- return ComputeRulesHash64(CurrentRulesRevision);
+ private RulesField(uint tag, string name, bool isInt64, long value)
+ {
+ Tag = tag;
+ Name = name;
+ IsInt64 = isInt64;
+ Value = value;
+ }
+
+ public uint Tag { get; }
+ public string Name { get; }
+ public bool IsInt64 { get; }
+ public long Value { get; }
+
+ public static RulesField Int64(uint tag, string name, long value) => new RulesField(tag, name, true, value);
+
+ public static RulesField Int32(uint tag, string name, int value) => new RulesField(tag, name, false, value);
+
+ public string Describe() => $"{Value} ({(IsInt64 ? "i64" : "i32")}, tag {Tag})";
}
/// Occupancy of one reserved slot (index 0..7).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3af38f47..43c1872d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -424,6 +424,15 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt
### Behoben
+- **Historische Regelrevisionen sind wieder eingefroren (#138, D-120-Entwurf).**
+ `MatchFingerprint.ComputeRulesHash64` hashte für jede Revision die heutigen
+ Konstanten; #131 hatte so die Identität von Revision 1 und 2 rückwirkend
+ verschoben. Jede Revision trägt jetzt eine Literalzeile, nur
+ `ComputeCurrentRulesHash64` liest live. Die Pins kehren zu den Originalwerten
+ zurück (Rev 1 `0x531CE8F614A16CB5`, Rev 2 `0x07725EA26668C9F8`), der aktuelle
+ `RulesHash64` bleibt `0xD1B683837D12FCEF`. Ein Stolperdraht-Test nennt
+ künftig die geänderte Konstante und verlangt eine neue Revision statt eines
+ neuen Pins.
- **Die Bauverweigerung nennt jetzt den Grund (#135).** Aus der Proberunde vom
31.08.2026 kamen zwei Beschwerden, die sich als **dieselbe** erwiesen: „Der
zweite Atlas kann keine Gebäude bauen" und „Ich konnte in der Kartenmitte kein
diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md
index c00a15ca..60b1f086 100644
--- a/docs/production/DecisionLog.md
+++ b/docs/production/DecisionLog.md
@@ -3662,6 +3662,114 @@ berührt.
Ansturm-Gegner ab. Wer ohne Menü startet (AutoStart, Harness), spielt diesen,
nicht r8.
+### D-120 | Entwurf | Regelrevisionen werden append-only und tragen eingefrorene Literalzeilen
+
+**Status:** Entwurf aus dem Issue-Lauf vom 2026-10-17 (#138); verbindlich mit
+dem Merge des PR.
+
+**Kontext:** `MatchFingerprint.ComputeRulesHash64(rev)` sollte laut Docstring
+frühere Regeln „exactly“ abbilden. Tatsächlich war nur die **Feldzahl** (5/7/14)
+revisionsabhängig; die **Werte** las die Funktion für jede Revision live aus
+`EconomySystem`, `ConstructionSystem` und `SimDefinitions`. Am Code geprüft
+(`main` @ `a6858d1`): Von den 13 gehashten Konstanten hat sich seit ihrer
+Einführung genau eine bewegt, `HqBaseCapacityAE` von 2.000 auf 3.000 in
+`e4faa75` (#131, 2026-08-31). Das hat drei Identitäten umgeschrieben:
+
+- Revision 1 `0x531CE8F614A16CB5` → `0x1163158B8146B168`,
+- Revision 2 `0x07725EA26668C9F8` → `0x259E6977B751109B`,
+- Revision 3, die aktuelle, `0x05CCA8475789AD4A` → `0xD1B683837D12FCEF` –
+ **ohne Revisionssprung**, entgegen dem Append-only-Sinn von D-104 Punkt 8.
+
+Die Pins wurden in `e4faa75` neu gesetzt, mit einem Testkommentar „NOT actually
+frozen“. Kein Produktivpfad berechnet historische Revisionen (`MatchBootstrap`
+und `Determinism10000Scenario` nutzen nur `ComputeCurrentRulesHash64`); der
+Defekt betraf die Aussagekraft der Replay-/Relay-Tests und des Docstrings.
+`Scripts/Simulation/Replays/` ist „niemand ohne D-ID“
+([13-15_Parallelbetrieb.md](hashkrieg/13-15_Parallelbetrieb.md)). Überholt sind
+damit auch die Aussagen in `reports/v8.6.0/sprint-23/13-kimi-reparatur.md`
+(Abschnitt zu `MatchFingerprintTests.cs`), Rev-1/2-Pins blieben unverändert und
+ein Revisionssprung friere Revision 3 ein: Beim Schreiben (`c1a5ebf`) stimmten
+beide Aussagen noch, waren unter dem alten Code aber nicht dauerhaft gesichert
+und wurden 13 Minuten später durch #131 (`e4faa75`) überholt.
+
+**Alternativen:**
+
+1. **Nur V1/V2 als Literale einfrieren.** Billig, Pins kehren zurück. Der
+ Defekt wandert aber nur zur aktuellen Revision: Die nächste Änderung einer
+ gehashten Konstante deutet V3 wieder still um, genau wie #131. Verworfen.
+2. **Alle Revisionen als Literalzeilen, Live-Pfad nur für den aktuellen Build,
+ dazu ein Stolperdraht.** V1/V2 kehren zu ihren Originalwerten zurück, V3
+ friert den Stand seit `e4faa75` ein, der aktuelle `RulesHash64` bewegt sich
+ nicht. Ein Test vergleicht die Live-Konstanten Feld für Feld mit der
+ Literalzeile der aktuellen Revision. Kosten: Jede Balanceänderung an einer
+ der gehashten Konstanten erzwingt eine neue Revision. Die Identität von
+ Revision 3 vor #131 bleibt nicht abbildbar.
+3. **Revision 3 rückwirkend teilen** (V3 = HQ 2.000, heutiger Stand = V4).
+ Wirkt lückenlos, ist es aber nicht: Revisionsnummer und Feldzahl gehen in den
+ Hash ein, der heutige Stand als V4 ergibt zwangsläufig einen anderen Wert als
+ `0xD1B683837D12FCEF`. Damit verlöre man die Identität aller Builds seit
+ 2026-08-31, also das jüngere, betarelevante Fenster, und jeder verteilte
+ Build würde ungültig. Verworfen.
+4. **In #108 aufgehen lassen.** Kein eigener Eingriff jetzt; bis dahin bleibt
+ der Testkommentar die einzige Absicherung, und jede weitere
+ Konstantenänderung überschreibt die Historie erneut. Verworfen.
+5. **Tabelle bekannter Identitäten mit zwei Zeilen für Revision 3** (vor und
+ nach #131, über einen Variantenschlüssel). Wäre tatsächlich lückenlos, ohne
+ den aktuellen Hash zu bewegen, braucht aber einen Schlüssel neben der
+ Revisionsnummer, den kein Fingerprint transportiert. Ohne Produktivkonsumenten
+ lohnt das nicht. Verworfen.
+
+**Entscheidung:** Alternative 2.
+
+1. **Regelrevisionen sind append-only.** Jede Revision trägt eine eingefrorene
+ Literalzeile in `MatchFingerprint.cs`; eine Zeile wird einmal geschrieben,
+ wenn ihre Revision angelegt wird, und danach nie mehr geändert.
+2. `ComputeRulesHash64(rev)` liest **nur** diese Literalzeilen. Unbekannte
+ Revisionen werfen weiter.
+3. Nur `ComputeCurrentRulesHash64()` liest die lebenden Konstanten, im Layout
+ von `CurrentRulesRevision`.
+4. **Jede Wertänderung einer gehashten Konstante bedeutet eine neue
+ Regelrevision** mit eigener Literalzeile. Einen Golden-Hash neu zu pinnen
+ oder eine Literalzeile zu ändern, ist nie die Abhilfe.
+5. Der Stolperdraht `MatchFingerprint.DescribeCurrentRulesDrift()` vergleicht
+ Live-Konstanten und Literalzeile der aktuellen Revision Feld für Feld (Tag,
+ Breite, Wert); der Test `CurrentLiveRules_MatchTheFrozenCurrentRevision`
+ meldet dann die geänderte Konstante mit „nächste RulesRevision anhängen,
+ nicht neu pinnen“. Ein Quelltextwächter
+ (`FrozenRevisions_ReadNoLiveConstants`) prüft, dass der markierte
+ Literalbereich keine lebenden Konstanten referenziert.
+6. Werte: V1 = 2.000/2.000/25/10 (`e1970a9`), V2 = zusätzlich 10/5
+ (`8512e9f`), V3 = 3.000/2.000/25/10/10/5/3/30/8/2/1/3/2 (`4cdcf07`, Stand
+ seit `e4faa75`). Pins: V1 `0x531CE8F614A16CB5`, V2 `0x07725EA26668C9F8`,
+ V3 und aktuell `0xD1B683837D12FCEF`.
+7. **Hingenommene Lücke:** Die Identität von Revision 3 vor #131
+ (`0x05CCA8475789AD4A`, Builds vom 2026-08-10 bis 2026-08-31) ist nicht
+ abbildbar. Dass #131 Revision 3 ohne Revisionssprung umgedeutet hat, darf
+ sich nicht wiederholen; Punkt 4 und der Stolperdraht verhindern es.
+
+**Begründung:** Nur Literalzeilen machen die Zusicherung „frozen“ nachweisbar
+wahr; die Original-Pins aus `e1970a9` und `8512e9f` kommen bitgenau zurück. Der
+aktuelle `RulesHash64` bleibt `0xD1B683837D12FCEF`, kein verteilter Build wird
+ungültig, keine der vier Guard-Baselines (D-091) bewegt sich. Der Stolperdraht
+verwandelt die stille Umdeutung aus #131 in einen roten Test, der die
+Konstante beim Namen nennt.
+
+**Konsequenzen:**
+
+- **Mehr Reibung bei Balance.** Jede Änderung an einer der 13 gehashten
+ Konstanten (z. B. `RepairCostPercent`, Q-047) wird eine neue Regelrevision in
+ `Replays/`. Ob das Anhängen einer reinen Wertrevision durch diese D-ID
+ stehend freigegeben ist oder je eine eigene D-ID braucht, entscheidet der
+ Inhaber beim Merge.
+- **Anhängen ist mechanisch:** `RulesRevisionVN` deklarieren, Literalzeile
+ `FrozenRulesRowVN` anlegen (heutige Werte als Literale plus neue Felder;
+ `SimFixed`-Felder als Rohwert), Fall in `GetFrozenRulesRow` ergänzen,
+ `BuildLiveCurrentRulesRow` um die Live-Lesungen erweitern,
+ `CurrentRulesRevision` umstellen, neuen Pin neben die alten setzen.
+- **Folgearbeiten:** Die `RulesRevisionV4` des Art-Strangs (Bewegung, Felder
+ 15/16) und #108 hängen ihre Revisionen nach diesem Muster als Literalzeilen
+ an; wer zuerst landet, bekommt V4, der andere die nächste Nummer.
+
## Offene Punkte
- Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden.
diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/01-issue-138-rules-revisions-frozen.md b/reports/v8.6.0/issue-lauf-2026-10-17/01-issue-138-rules-revisions-frozen.md
new file mode 100644
index 00000000..852867a3
--- /dev/null
+++ b/reports/v8.6.0/issue-lauf-2026-10-17/01-issue-138-rules-revisions-frozen.md
@@ -0,0 +1,126 @@
+---
+issue: 138
+paket: P-138
+branch: fix/138-freeze-rules-revisions
+agent: builder (opus)
+datum: 2026-10-17
+status: DONE
+---
+# Historische Regelrevisionen als Literalzeilen eingefroren, Stolperdraht für die aktuelle Revision
+
+## Aufgabe
+
+`MatchFingerprint.ComputeRulesHash64(rev)` hashte für jede Revision die heutigen
+Konstanten; nur die Feldzahl war historisch. #131 (`e4faa75`) hat dadurch die
+Identität der Revisionen 1 und 2 und ohne Revisionssprung auch die von Revision 3
+verschoben. Umgesetzt ist Option B aus der Triage: Jede Revision trägt eine
+eingefrorene Literalzeile, nur `ComputeCurrentRulesHash64` liest live, und ein
+Stolperdraht meldet jede Abweichung der Live-Konstanten von der Literalzeile der
+aktuellen Revision. Dazu kommen der D-120-Entwurf und ein Changelog-Eintrag.
+
+## Was geändert wurde — Files Changed
+
+- `Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs`: Die Regelidentität ist jetzt zeilenbasiert. Neu sind die private Struktur `RulesField` (Tag, Breite i32/i64, Wert, diagnostischer Name) und die Schreibroutine `HashRulesRow`; der Bytestrom ist unverändert (Stub-Tag 1, Feldzahl = Zeilenlänge + 1, Feld 1 = Revision als u16, dann Tag + Wert je Feld). Im markierten Bereich `// ` liegen die drei Literalzeilen `FrozenRulesRowV1/V2/V3` und `GetFrozenRulesRow`; unbekannte Revisionen werfen weiter. `BuildLiveCurrentRulesRow` ist die einzige Stelle, die die lebenden Konstanten liest. Neu ist auch die öffentliche Methode `DescribeCurrentRulesDrift()` als Feld-für-Feld-Stolperdraht: Ein Testzugriff ist nötig, und es gibt kein `InternalsVisibleTo`. Berichtigt sind die Docstrings von `RulesRevisionV2` (hieß noch „Current …“), `CurrentRulesRevision` (Append-only-Regel), `ComputeRulesHash64` (Literalzeilen, hingenommene Lücke `0x05CCA8475789AD4A`) und `ComputeCurrentRulesHash64` (Live-Pfad plus Stolperdraht). `using System.Collections.Generic` ist ergänzt.
+- `tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs`: Die Pins für Rev 1/2 stehen wieder auf `0x531CE8F614A16CB5` / `0x07725EA26668C9F8` (Herkunft `e1970a9`/`8512e9f`), der Kommentar „NOT actually frozen“ ist entfernt. Die bestehende Assertion `current == ComputeRulesHash64(V3)` trägt jetzt die Stolperdraht-Meldung. Drei Tests sind neu: `RulesRevisionThree_FrozenLiteral_IsTheCurrentStream` (Pin `0xD1B683837D12FCEF` direkt auf V3, plus Wurf für Revision 0 und `CurrentRulesRevision + 1`), `CurrentLiveRules_MatchTheFrozenCurrentRevision` (Feld-für-Feld-Diagnose) und `FrozenRevisions_ReadNoLiveConstants` (Quelltextwächter auf den markierten Bereich: Jeder `RulesField.IntXX(...)`-Eintrag muss ein Ganzzahlliteral tragen, auch hex, und außer den `RulesField`-Fabriken ist kein Memberzugriff erlaubt).
+- `Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs`: EditMode-Spiegel mit denselben Änderungen. Nur die Pfadauflösung unterscheidet sich (`UnityEngine.Application.dataPath`, voll qualifiziert, ohne neues `using UnityEngine`).
+- `docs/production/DecisionLog.md`: Der Entwurf `### D-120 | Entwurf | …` steht hinter D-114 und vor „## Offene Punkte“, mit fünf bewerteten Alternativen. Kopfversion und Änderungsverlauf sind unberührt; die zieht der Integrator nach.
+- `CHANGELOG.md`: Ein Absatz unter `[Unreleased]` → `### Behoben` mit „(#138, D-120-Entwurf)“.
+
+Unberührt sind die vier Guard-Dateien, `docs/README.md` und `ScopeLedger.md`.
+
+## Tests und Prüfungen
+
+Umgebung: `export DOTNET_ROOT="/Volumes/2TB_CodingProjekte/Coding_Projekte/Project Nova/.dotnet"; export PATH="$DOTNET_ROOT:$PATH"`, ausgeführt im Worktree.
+
+| Befehl | Ergebnis |
+|---|---|
+| `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` | **781/781 grün**, 0 Fehler, 14 s (Basis 778 + 3 neue Tests) |
+| `python3 .github/scripts/check_docs.py` | Exit 0, „OK: 203 Markdown-Dateien und 5 Quality-JSONs geprüft.“ (nur die bekannten Hinweise auf fehlende Status-Kopfzeilen) |
+| `python3 .github/scripts/check_baseline_guard.py --self-test` | „Baseline guard self-test passed, including the required negative control.“ |
+
+**Selbst nachgemessen** mit einem Scratch-Programm (`scratchpad/build-138/measure`) gegen die frisch gebaute `Nova.SimRunner.Tests.dll` dieses Worktrees:
+
+- `ComputeRulesHash64(1)` = `0x531CE8F614A16CB5`
+- `ComputeRulesHash64(2)` = `0x07725EA26668C9F8`
+- `ComputeRulesHash64(3)` = `0xD1B683837D12FCEF`
+- `ComputeCurrentRulesHash64()` = `0xD1B683837D12FCEF` (Revision 3, also unverändert gegenüber `main`)
+- `DescribeCurrentRulesDrift()` liefert 0 Zeilen.
+- Referenz mit dem alten Schreiblayout: Rev 3 mit HQ 2.000 = `0x05CCA8475789AD4A` (die hingenommene Lücke), Rev 1 mit HQ 3.000 = `0x1163158B8146B168`, Rev 2 mit HQ 3.000 = `0x259E6977B751109B` (die alten Neu-Pins). Die Triage-Werte sind damit bestätigt.
+- Historische Konstanten per `git show` geprüft: `e1970a9` 2000/2000/25/10, `8512e9f` 10/5, `4cdcf07` 3/30/8/2/1/3/2.
+
+**Mutationsproben**, jeweils danach aus einer Sicherungskopie im Scratchpad zurückgespielt, Diff anschließend sauber:
+
+1. `EconomySystem.HqBaseCapacityAE` 3000 → 4000: Rot werden `CurrentLiveRules_MatchTheFrozenCurrentRevision` mit der Meldung `revision 3 field 2 (HqBaseCapacityAE): frozen 3000 …, live 4000 …` und `CurrentRulesHash_MovesPastRevisionTwo…`. Die Pins für Rev 1/2/3 sowie ReplayTests und LockstepNetworkTests bleiben grün (Filterlauf 103/105).
+2. Ein Wert der V1-Literalzeile durch `EconomySystem.ExcessDecayPercent` ersetzt: Rot wird `FrozenRevisions_ReadNoLiveConstants` (22 von 23 Einträgen sind Literale).
+
+**Nicht gelaufen:**
+
+- **Unity-EditMode-Spiegel** (`MatchFingerprintV1Tests.cs`): nicht kompiliert und nicht ausgeführt, weil keine CI dafür existiert und das Paket keinen Unity-Start vorsieht. Er ist textgleich zur .NET-Lane bis auf die Pfadauflösung (per `diff` geprüft).
+- `check_baseline_guard.py` lief nur als `--self-test`, nicht gegen den realen Diff. Nach der Triage greift er nicht, weil `MatchFingerprintTests.cs` keine der vier `BASELINE_FILES` ist.
+- Keine Spielrunde. Am Laufzeitverhalten ändert sich nichts, der aktuelle `RulesHash64` ist bitgleich.
+
+## Entscheidung
+
+Der D-120-Entwurf („Regelrevisionen werden append-only und tragen eingefrorene Literalzeilen“) steht in `docs/production/DecisionLog.md`. Kurzfassung: Revisionen sind append-only und tragen Literalzeilen. Nur `ComputeCurrentRulesHash64` liest live. Jede Wertänderung einer gehashten Konstante bedeutet eine neue Revision, nie einen neuen Pin. Der Stolperdraht und der Quelltextwächter setzen das durch. Die Lücke `0x05CCA8475789AD4A` (Revision 3 vor #131, Builds 10.–31.08.2026) wird hingenommen. Dass #131 Revision 3 ohne Revisionssprung umgedeutet hat (entgegen D-104 Punkt 8), ist festgehalten, ebenso dass die Aussagen in `reports/v8.6.0/sprint-23/13-kimi-reparatur.md` dazu überholt sind. Verworfen wurden: nur V1/V2 einfrieren, V3 rückwirkend teilen (nicht lückenlos, sondern verliert alle Builds seit 31.08.), in #108 aufgehen lassen, Identitätstabelle mit Variantenschlüssel.
+
+### Wie der Art-Strang (`feat/generals-look-and-feel`, RulesRevisionV4 für D-116) nachziehen muss
+
+Landet #138 zuerst, muss der Art-Strang beim Rebase seine V4 so umbauen:
+
+1. `public const ushort RulesRevisionV4 = 4;` bleibt wie im Art-Diff, `CurrentRulesRevision = RulesRevisionV4`.
+2. **Nicht** die alten `if (rulesRevision >= …)`-Blöcke wiederherstellen. Stattdessen im Bereich `// ` eine Zeile `FrozenRulesRowV4` anlegen: die 13 Einträge von V3 unverändert kopieren, dazu
+ `RulesField.Int32(15, "MaxRadius", ),` und
+ `RulesField.Int32(16, "LargeRadius", ),`.
+ Die Rohwerte misst man einmal aus (z. B. per Testausgabe) und trägt sie als Dezimal- oder Hexliteral ein. Ein Ausdruck wie `MovementSpacing.MaxRadius.RawValue` lässt der Quelltextwächter nicht durch.
+3. `GetFrozenRulesRow` um `case RulesRevisionV4: return FrozenRulesRowV4;` ergänzen.
+4. `BuildLiveCurrentRulesRow` um zwei Live-Einträge erweitern:
+ `RulesField.Int32(15, nameof(MovementSpacing.MaxRadius), MovementSpacing.MaxRadius.RawValue),` und entsprechend Feld 16.
+ `Movement.` bzw. `using Nova.Simulation.Movement` braucht man nur dort, nicht im Literalbereich.
+5. Tests: Der Art-Pin `0x7E2E3E8C4CF1591E` muss sich ergeben, wenn die Literale stimmen. `RulesRevisionThree_FrozenLiteral_IsTheCurrentStream` heißt dann besser `RulesRevisionThree_FrozenLiteral_RemainsByteStable`, sein Kommentar „IsTheCurrentStream“ ist überholt. `CurrentRulesHash_MovesPast…` wie im Art-Diff auf V4 umstellen. Der Quelltextwächter erwartet ≥ 23 Einträge und bleibt ohne Änderung grün. `CurrentLiveRules_MatchTheFrozenCurrentRevision` braucht keine Änderung.
+
+Landet der Art-Strang zuerst, zieht dieses Paket nach. Dann friert es zusätzlich V4 als Literalzeile ein (Werte aus `MovementSpacing`), und der aktuelle Hash ist der V4-Pin.
+
+## Nebenbefunde
+
+- **Feldtags 15/16 und die Nummer V4 sind mehrfach verplant.** Der Art-Strang belegt V4 mit Feldern 15/16 für die Bewegung (D-116). `reports/v8.6.0/sprint-23/13-kimi-reparatur.md` (Zeilen ~290–298) plant ebenfalls eine `RulesRevisionV4` mit Feldern 15/16, dort für `PassiveRepairRateHpPerTick`/`PassiveRepairRadiusCells`, und #108 will auch eine V4. Wer später kommt, bekommt die nächste Revision und die nächsten freien Tags.
+- Derselbe Kimi-Bericht behauptet, die Rev-1/2-Pins blieben unverändert. Unter dem alten Code war das falsch, unter dem neuen stimmt es wieder. Im D-120-Entwurf ist das vermerkt, den Bericht selbst habe ich nicht angefasst.
+- D-115/D-116 kommen im Art-Code als Verweise vor, stehen aber in keinem DecisionLog. Laut Triage sind sie nicht formal reserviert.
+- `docs/production/hashkrieg/21_Sprint_Verknappungsfolgen.md:518` sagt, 21.1 Teil b (Ankerliste, D-108) bewege `RulesHash64`. Die gehashten Konstanten decken die Ankerliste aber nicht ab, das ist genau #108. Die Aussage wirkt ungenau, ich habe sie nicht geprüft.
+- `DescribeCurrentRulesDrift()` ist neue öffentliche API in `Simulation/Replays/`. Nach Core Rule 4 wäre ein Blick von @api-guardian angebracht, auch wenn die Methode nur additiv und rein diagnostisch ist.
+
+## Offene Fragen an den Inhaber
+
+1. Gibst du D-120 (Option B) für `Replays/MatchFingerprint.cs` frei? Die Endnummer vergibt der Integrator.
+2. Soll eine reine Wertrevision (neue Literalzeile wegen geänderter Balance-Konstante) durch D-120 stehend freigegeben sein, oder braucht jede neue Revision eine eigene D-ID?
+3. Reihenfolge gegenüber dem Art-Strang (V4/D-116): #138 zuerst (empfohlen, der Art-Strang legt V4 dann gleich als Literalzeile an) oder Art-Strang zuerst?
+4. Bestätigst du die Lücke `0x05CCA8475789AD4A` (Revision 3 vom 10.–31.08.2026) als hingenommen?
+
+## Vorschlag für Commit-Titel und PR-Beschreibung
+
+**Commit-Titel:** `fix(replays): freeze rules revisions as literal rows (#138, D-120)` (66 Zeichen)
+
+**PR-Beschreibung:**
+
+> **Was:** `MatchFingerprint.ComputeRulesHash64(rev)` liest nur noch eingefrorene Literalzeilen je Revision. `ComputeCurrentRulesHash64()` liest als einziger Pfad die lebenden Konstanten. `DescribeCurrentRulesDrift()` vergleicht beide Feld für Feld (Stolperdraht), und ein Quelltextwächter verbietet Live-Referenzen im Literalbereich. D-120-Entwurf im DecisionLog.
+>
+> **Warum:** Bisher war nur die Feldzahl historisch, die Werte waren immer die heutigen. #131 hat so die Identität der Revisionen 1/2 und, ohne Revisionssprung, auch die von Revision 3 verschoben (#138).
+>
+> **Bereiche:** `Simulation/Replays/MatchFingerprint.cs` (gesperrter Pfad, „niemand ohne D-ID“ → D-120), `MatchFingerprintTests.cs` plus EditMode-Spiegel, DecisionLog, CHANGELOG.
+>
+> **Hashes:** Der aktuelle `RulesHash64` bleibt `0xD1B683837D12FCEF`, kein verteilter Build wird ungültig. Die Rev-1/2-Pins kehren zu den Werten vor `e4faa75` zurück: `0x531CE8F614A16CB5` / `0x07725EA26668C9F8`. Keine der vier Guard-Baselines (D-091) ist berührt. Die Pins bewegen sich im selben PR wie der Code, weil der Code ohne sie rot wäre. Der Laufzeit-Hash ist unverändert.
+>
+> **Changelog:** `[Unreleased]` → Behoben: „Historische Regelrevisionen sind wieder eingefroren (#138, D-120-Entwurf).“
+>
+> **Tests:** `dotnet test` 781/781 grün. `check_docs.py` OK, `check_baseline_guard.py --self-test` OK. Mutationsproben: Konstante geändert → Stolperdraht nennt sie; Literal durch Live-Referenz ersetzt → Quelltextwächter rot.
+>
+> **Unity-Spuren:** EditMode-Spiegel `MatchFingerprintV1Tests` **nicht gelaufen** (keine Unity-CI, kein lokaler Unity-Lauf).
+>
+> **Spielabnahme:** nicht gespielt. Grund: Es gibt keine Verhaltensänderung, der aktuelle Regelhash ist bitgleich. Ersatznachweis: volle Simulationssuite plus Hash-Nachmessung. Restrisiko: Der EditMode-Spiegel kompiliert erst beim nächsten Unity-Lauf.
+>
+> **Folgen:** Der Art-Strang (RulesRevisionV4, D-116) und #108 hängen ihre Revisionen als Literalzeilen an. Vor dem Selbst-Merge ist ein unabhängiges Read-only-Review nötig (AGENTS.md §3).
+>
+> Fixes #138, Refs #108
+
+## Unity-Spuren nachgefahren (Integrator, 2026-10-17)
+
+Nach dem Commit mit `tools/unity-tests/run_unity_tests.py` (Branch #110) auf diesem Worktree, Unity 6000.5.4f1, warme Library: **EditMode 666/666, PlayMode 15/15**, keine Fehlschläge, keine Compile-Fehler, Arbeitsbaum danach sauber. Die Zeile „Unity-Spuren: nicht gelaufen“ oben ist damit überholt; der PR-Text nennt die gemessenen Zahlen.
diff --git a/reports/v8.6.0/issue-lauf-2026-10-17/01b-review-1-issue-138.md b/reports/v8.6.0/issue-lauf-2026-10-17/01b-review-1-issue-138.md
new file mode 100644
index 00000000..d42ef280
--- /dev/null
+++ b/reports/v8.6.0/issue-lauf-2026-10-17/01b-review-1-issue-138.md
@@ -0,0 +1,182 @@
+---
+issue: 138
+paket: P-138
+branch: fix/138-freeze-rules-revisions
+reviewer: opus
+runde: 1
+datum: 2026-10-17
+verdict: APPROVED
+---
+# Review 1 zu #138: Regelrevisionen als Literalzeilen eingefroren
+
+Unabhängiges Read-only-Review nach AGENTS.md §3. Ich habe keine Projektdatei
+geändert. Hilfsprogramme und Mutationsproben liefen in einer Kopie unter
+`scratchpad/build-138/review-hash` und `scratchpad/build-138/review-mut`,
+nicht im Worktree.
+
+## Was geprüft wurde
+
+- `git status --short` und `git diff` vollständig: geändert sind genau die fünf
+ Dateien aus dem Schreibumfang der Paketspezifikation (`MatchFingerprint.cs`,
+ `MatchFingerprintTests.cs`, `MatchFingerprintV1Tests.cs`, `DecisionLog.md`,
+ `CHANGELOG.md`), dazu untrackt der Builder-Bericht. Die vier Guard-Dateien
+ (D-091) sind unberührt, `git status` meldet für sie nichts.
+- Abgleich mit Issue #138, Triage und Paketspezifikation P-138: alle neun
+ Schritte und alle vier geforderten Tests sind umgesetzt.
+- Bytestrom: `HashRulesRow` schreibt Stub-Tag 1, Feldzahl `row.Length + 1`
+ (V1 4+1 = 5, V2 6+1 = 7, V3 13+1 = 14), Feld 1 als u16, dann Tag 2/3 als i64
+ und Tag 4–14 als i32. Das entspricht dem alten Writer auf `a6858d1`.
+- Historische Werte per `git grep ` nachgesehen: `e1970a9` 2000/2000/25/10,
+ `8512e9f` zusätzlich 10/5, `4cdcf07` 2000/…/3/30/8/2/1/3/2, `e4faa75` HQ 3000.
+ Die Literalzeilen V1/V2/V3 stimmen damit überein. Commitdaten:
+ `4cdcf07` 2026-08-10, `e4faa75` 2026-08-31, passend zur benannten Lücke.
+- Determinismus: keine float/double, keine Zeit- oder Zufallsquelle, keine
+ Dictionary/HashSet-Iteration. `List` und String-Interpolation kommen
+ nur im Diagnosepfad `DescribeCurrentRulesDrift` vor, nicht im Hash.
+- Statische Initialisierung: In `MatchFingerprint` gibt es nur die drei neuen
+ `static readonly`-Zeilen; kein früherer statischer Initialisierer ruft den
+ Regelhash auf. Es gibt also kein Null-Risiko durch die Textreihenfolge.
+- Aufrufer: Nur `MatchBootstrap` und `Determinism10000Scenario` rufen produktiv
+ `ComputeCurrentRulesHash64()` auf. `ComputeRulesHash64(V1/V2)` nutzen nur
+ Replay- und Lockstep-Tests. Die Aussage im D-120-Kontext stimmt.
+- EditMode-Spiegel: Per `diff` ist der neue Block textgleich zur .NET-Lane bis
+ auf `ResolveMatchFingerprintSource` (`UnityEngine.Application.dataPath`, wie
+ in `NoFloatInSimulationTests`). Die asmdef `Nova.Simulation.Tests` hat
+ Engine-Referenzen. Gelesen, nicht kompiliert.
+- Art-Strang: `git diff` im Hauptarbeitsbaum lesend angesehen. V4 liest dort
+ `Movement.MovementSpacing.MaxRadius/LargeRadius.RawValue` (Typ `int`). Damit
+ ist die Nachzieh-Anleitung im Builder-Bericht (`RulesField.Int32(15/16, …)`
+ mit Literal in der Frozen-Zeile, Live-Lesung in `BuildLiveCurrentRulesRow`)
+ typkorrekt und vollständig.
+- DecisionLog: D-120 steht direkt nach D-114 und vor „## Offene Punkte“. Er hat
+ Status „Entwurf“, Kontext, fünf bewertete Alternativen, Entscheidung,
+ Begründung und Konsequenzen. Kopfversion und Änderungsverlauf sind unberührt.
+ D-104 Punkt 8 (append-only `RulesRevisionV3`) und Q-047 existieren. Der Link
+ auf `hashkrieg/13-15_Parallelbetrieb.md` funktioniert, die Zeile 85 dort
+ sagt „niemand ohne D-ID“.
+- CHANGELOG: Ein Absatz unter `## [Unreleased]` → `### Behoben` mit
+ „(#138, D-120-Entwurf)“, ohne Versionsüberschrift.
+
+## Nachgerechnete Ergebnisse
+
+| Prüfung | Befehl | Ergebnis (selbst gemessen) |
+|---|---|---|
+| Simulationstests | `dotnet test tools/Nova.SimRunner.Tests/Nova.SimRunner.Tests.csproj -c Release` | **781/781 grün**, 0 Fehler, 14 s |
+| Doku-Links | `python3 .github/scripts/check_docs.py` | Exit 0, „OK: 204 Markdown-Dateien …“. Der Builder meldete 203; der Unterschied ist sein eigener, inzwischen angelegter Bericht, denn das Skript zählt auch untrackte `.md` |
+| Baseline-Guard | `python3 .github/scripts/check_baseline_guard.py --self-test` | „passed, including the required negative control“ |
+| Baseline-Guard gegen den realen Diff | `--changed-files --labels ` | „Baseline guard passed: the PR changes only one side of the separation.“ (lief beim Builder nicht) |
+
+**Hashes**, gemessen mit einem eigenen Scratch-Programm gegen die gebaute
+`Nova.SimRunner.Tests.dll`, zusätzlich mit einer **unabhängigen**
+Nachimplementierung des alten Schreiblayouts über `SimHashWriter` und
+expliziten Werten:
+
+| Größe | neue API | unabhängig, altes Layout |
+|---|---|---|
+| Rev 1 (HQ 2000) | `0x531CE8F614A16CB5` | `0x531CE8F614A16CB5` |
+| Rev 2 (HQ 2000) | `0x07725EA26668C9F8` | `0x07725EA26668C9F8` |
+| Rev 3 (HQ 3000) | `0xD1B683837D12FCEF` | `0xD1B683837D12FCEF` |
+| aktuell | `0xD1B683837D12FCEF` | – |
+| Drift-Zeilen | 0 | – |
+| Rev 3 mit HQ 2000 (Lücke) | – | `0x05CCA8475789AD4A` |
+| Rev 1/2 mit HQ 3000 (alte Neu-Pins) | – | `0x1163158B8146B168` / `0x259E6977B751109B` |
+
+Der aktuelle Regelhash bewegt sich nicht. Die Rev-1/2-Pins kehren bitgenau zu
+den Originalwerten zurück.
+
+**Mutationsproben**, in einer Kopie der Quellen im Scratchpad, nicht im Worktree:
+
+- A: Live-`HqBaseCapacityAE` 3000 → 4000. Gefilterter Lauf
+ (`MatchFingerprintTests|ReplayTests|LockstepNetworkTests`): **103/105**. Rot
+ werden genau `CurrentLiveRules_MatchTheFrozenCurrentRevision` mit
+ „revision 3 field 2 (HqBaseCapacityAE): frozen 3000 (i64, tag 2), live 4000
+ (i64, tag 2)“ samt Append-Hinweis und
+ `CurrentRulesHash_MovesPastRevisionTwo…`. Die Rev-1/2/3-Pins bleiben grün.
+ Die Behauptung des Builders ist bestätigt.
+- B: Ein Literal der V1-Zeile durch `EconomySystem.ExcessDecayPercent`
+ ersetzt. `FrozenRevisions_ReadNoLiveConstants` wird rot (Expected 23, But
+ was 22), 11/12. Bestätigt.
+- C (eigene Probe, der verbotene Weg): Frozen-V3-Zeile **und** Live-Konstante
+ gemeinsam auf 4000 gesetzt, also Zeile editieren statt Revision anhängen. Der
+ Stolperdraht bleibt erwartungsgemäß grün, aber
+ `RulesRevisionThree_FrozenLiteral_IsTheCurrentStream` und der Current-Pin
+ werden rot („revision 3 canonical stream is frozen“), 10/12. Damit ist auch
+ dieser Umweg abgesichert, solange niemand bewusst den Pin neu setzt. Das
+ verbietet D-120 Punkt 4.
+
+**Nicht gelaufen** (beim Builder und bei mir): der Unity-EditMode-Spiegel
+`MatchFingerprintV1Tests.cs`, weil es keine Unity-CI gibt und kein Unity-Start
+vorgesehen war. Eine Spielrunde gab es ebenfalls nicht; das ist vertretbar,
+weil der aktuelle `RulesHash64` bitgleich bleibt und die Simulation unverändert
+ist.
+
+## Befunde
+
+### hoch
+
+Keine.
+
+### mittel
+
+Keine.
+
+### niedrig
+
+1. **D-120-Kontext überzeichnet den Kimi-Bericht**
+ (`docs/production/DecisionLog.md:3689-3692`, gleichlautend im
+ Builder-Bericht unter „Nebenbefunde“). Dort steht, unter dem alten Code
+ „stimmte beides nicht“. `reports/v8.6.0/sprint-23/13-kimi-reparatur.md`
+ entstand aber mit `c1a5ebf` (2026-08-31 10:29), also **vor** `e4faa75`
+ (10:42). Zu diesem Zeitpunkt waren die Rev-1/2-Pins `0x531…`/`0x077…` und
+ der Current-Pin `0x05CC…` korrekt. Die Aussagen waren nicht falsch, sondern
+ strukturell ungesichert und wurden 13 Minuten später durch #131 überholt.
+ Vorschlag: „überholt durch #131; unter dem alten Code nicht dauerhaft
+ gesichert“.
+2. **Zukunftsfestigkeit der Texte für V4**
+ (`Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs:293-306`,
+ `tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs:108`). Der Docstring
+ von `ComputeCurrentRulesHash64` beschreibt fest „Revision 3 retains …“, und
+ der Testname `…_IsTheCurrentStream` wird mit V4 falsch. Der Builder nennt
+ den Umbenennungsbedarf selbst in der Art-Anleitung. Handlungsbedarf besteht
+ jetzt keiner; beim V4-Anhängen mitziehen.
+3. **Frozen-Zeilen sind veränderliche Arrays**
+ (`MatchFingerprint.cs:417-468`). `private static readonly RulesField[]`
+ schützt nur die Referenz, nicht den Inhalt. Die Felder sind privat und
+ werden nur gelesen, deshalb gibt es heute kein Risiko. Wer mehr Härte will,
+ gibt `GetFrozenRulesRow` als `IReadOnlyList` zurück. Optional.
+4. **Quelltextwächter ignoriert Blockkommentare** (`MatchFingerprintTests.cs`,
+ `FrozenRevisions_ReadNoLiveConstants`). Entfernt werden nur `//`-Kommentare.
+ Ein `/* … */` mit Memberzugriff im Bereich würde einen falschen Alarm
+ auslösen, aber nie einen Defekt verdecken: Die Literalprüfung verlangt das
+ Literal direkt vor `)`. Kein Handlungsbedarf.
+5. **Hinweis zur Annahme im Auftrag:** `reports/` ist in diesem Repo nicht
+ gitignored (`git ls-files reports` liefert getrackte Berichte). Der Builder
+ hat das korrekt gemeldet. Der Integrator braucht kein `-f`.
+
+## Ehrlichkeit des Builder-Berichts
+
+Alle Zahlen habe ich nachgerechnet, und sie stimmen: 781/781, die Hashes, beide
+Mutationsproben einschließlich 103/105 und 22/23. Die Zählung von
+`check_docs.py` (203) passt zum Zeitpunkt vor dem Anlegen seines Berichts.
+Nicht-Gelaufenes ist ausgewiesen: Unity-Spiegel, Baseline-Guard nur als
+Self-Test, keine Spielrunde. Der Bericht hat alle geforderten Abschnitte. Die
+Art-Nachzieh-Anleitung ist konkret und typkorrekt.
+
+## Empfehlung für den Inhaber
+
+**APPROVED.** Die Änderung erfüllt #138 vollständig und lässt den aktuellen
+Regelhash bitgleich. Die drei Pins sind nachgemessen. Stolperdraht und
+Quelltextwächter schlagen nachweislich an. Vor dem Merge bleiben die
+Inhaberentscheidungen aus dem Builder-Bericht offen:
+
+1. D-120 freigeben (Option B, append-only Literalzeilen).
+2. Entscheiden, ob reine Wertrevisionen durch D-120 stehend freigegeben sind.
+ Meine Empfehlung: ja, solange der PR die neue Revision im CHANGELOG nennt;
+ eine eigene D-ID nur bei neuen Feldern.
+3. Reihenfolge: #138 vor dem Art-Strang. Die Anleitung im Builder-Bericht passt
+ zum aktuellen Art-Diff.
+4. Die Lücke `0x05CCA8475789AD4A` bestätigen.
+
+Der Unity-EditMode-Spiegel ist nicht gelaufen und sollte beim nächsten
+Unity-Start mitlaufen. Befund 1 kann der Integrator beim Merge im D-120-Text
+glätten; das ist keine Merge-Bedingung.
diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs
index 9a7c365d..ab5e340f 100644
--- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs
+++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs
@@ -1,4 +1,6 @@
using System;
+using System.IO;
+using System.Text.RegularExpressions;
using NUnit.Framework;
using Nova.Simulation.CommandsV1;
using Nova.Simulation.Replays;
@@ -79,20 +81,39 @@ public void ComputeHash_IsStableAcrossInstances_AndStubHashesAreDistinct()
"D-106 rules must not match the legacy empty rules stub");
}
+ ///
+ /// The failure text of every rules-revision tripwire (#138, D-120).
+ ///
+ private const string AppendRevisionHint =
+ "A hashed rules constant changed under an existing rules revision. Append the next " +
+ "RulesRevision with its own frozen literal row (MatchFingerprint, D-120) — do not " +
+ "re-pin a golden hash and do not edit a frozen row.";
+
[Test]
public void RulesRevisionOneAndTwo_GoldenHashesRemainByteStable()
{
ulong revisionOne = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV1);
ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2);
- // NOT actually frozen — see issue #138. ComputeRulesHash64 writes
- // TODAY's constants into every revision, so raising
- // HqBaseCapacityAE (#131) moved the revision-1 hash too. Re-pinning
- // here restores green; it does NOT restore the guarantee the name
- // claims. Do not read this assertion as proof of anything until #138
- // is decided.
- Assert.That(revisionOne, Is.EqualTo(0x1163158B8146B168UL), "revision 1 canonical stream is frozen");
- Assert.That(revisionTwo, Is.EqualTo(0x259E6977B751109BUL), "revision 2 canonical stream is frozen");
+ // Original pins (#138, D-120): revision 1 as introduced in e1970a9
+ // (HQ 2,000 / storage 2,000 / decay 25 % every 10 ticks), revision 2
+ // as introduced in 8512e9f (plus repair 10 / low-power repair 5).
+ // Both rows are literals now, so no later constant edit can move
+ // them; a red assertion here means a frozen row was edited.
+ Assert.That(revisionOne, Is.EqualTo(0x531CE8F614A16CB5UL), "revision 1 canonical stream is frozen");
+ Assert.That(revisionTwo, Is.EqualTo(0x07725EA26668C9F8UL), "revision 2 canonical stream is frozen");
+ }
+
+ [Test]
+ public void RulesRevisionThree_FrozenLiteral_IsTheCurrentStream()
+ {
+ // Revision 3 as of e4faa75 (#131, HQ 3,000). Its pre-#131 identity
+ // 0x05CCA8475789AD4A is the accepted D-120 gap.
+ Assert.That(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3),
+ Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen");
+ Assert.Throws(() => MatchFingerprint.ComputeRulesHash64(0));
+ Assert.Throws(() =>
+ MatchFingerprint.ComputeRulesHash64((ushort)(MatchFingerprint.CurrentRulesRevision + 1)));
}
[Test]
@@ -102,12 +123,88 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair()
ulong current = MatchFingerprint.ComputeCurrentRulesHash64();
Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3));
- Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3)));
+ Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3)),
+ AppendRevisionHint);
Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen");
Assert.That(current, Is.Not.EqualTo(revisionTwo),
"D-104 placement and repair behavior must not share revision 2's rules identity");
}
+ [Test]
+ public void CurrentLiveRules_MatchTheFrozenCurrentRevision()
+ {
+ // The tripwire (#138, D-120): the live constants must equal the
+ // frozen literal row of the current revision, field by field. The
+ // message names the moved constant instead of two opaque hashes.
+ string[] drift = MatchFingerprint.DescribeCurrentRulesDrift();
+
+ Assert.That(drift, Is.Empty, AppendRevisionHint + "\n" + string.Join("\n", drift));
+ Assert.That(MatchFingerprint.ComputeCurrentRulesHash64(),
+ Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.CurrentRulesRevision)),
+ AppendRevisionHint);
+ }
+
+ [Test]
+ public void FrozenRevisions_ReadNoLiveConstants()
+ {
+ // Source-text guard (the NoFloatInSimulationTests pattern): the
+ // frozen-row region of MatchFingerprint.cs may hold literal values
+ // only. A row that reads EconomySystem/ConstructionSystem/... would
+ // compile, hash identically today and silently unfreeze again with
+ // the next balance change — exactly the #138 defect.
+ string source = File.ReadAllText(ResolveMatchFingerprintSource());
+ const string OpenMarker = "// ";
+ const string CloseMarker = "// ";
+ int open = source.IndexOf(OpenMarker, StringComparison.Ordinal);
+ int close = source.IndexOf(CloseMarker, StringComparison.Ordinal);
+ Assert.That(open, Is.GreaterThanOrEqualTo(0), "frozen-row region start marker missing");
+ Assert.That(close, Is.GreaterThan(open), "frozen-row region end marker missing");
+ Assert.That(source.IndexOf(OpenMarker, open + 1, StringComparison.Ordinal), Is.EqualTo(-1),
+ "exactly one frozen-row region expected");
+
+ // Comments go first: they may name live constants freely.
+ string region = Regex.Replace(source.Substring(open, close - open), @"//[^\n]*", string.Empty);
+
+ // Every row entry is RulesField.IntXX(, "", );
+ // a SimFixed field is frozen as its raw value (decimal or hex).
+ int entries = Regex.Matches(region, @"RulesField\s*\.\s*Int(32|64)\s*\(").Count;
+ int literalEntries = Regex.Matches(region,
+ @"RulesField\s*\.\s*Int(32|64)\s*\(\s*\d+u?\s*,\s*""[^""]*""\s*,\s*-?(0[xX][0-9A-Fa-f]+|\d+)[Ll]?\s*\)").Count;
+ Assert.That(entries, Is.GreaterThanOrEqualTo(4 + 6 + 13), "expected the rows of revisions 1-3");
+ Assert.That(literalEntries, Is.EqualTo(entries),
+ "every frozen row value must be an integer literal, never a live constant");
+
+ // Strings blanked: no member access other than the RulesField factories.
+ string code = Regex.Replace(region, @"""[^""]*""", "\"\"");
+ MatchCollection access = Regex.Matches(code, @"\b(?!RulesField\b)[A-Za-z_]\w*\s*\.\s*[A-Za-z_]\w*");
+ var offenders = new string[access.Count];
+ for (int i = 0; i < access.Count; i++) offenders[i] = access[i].Value;
+ Assert.That(offenders, Is.Empty,
+ "the frozen-row region must not reference live constants: " + string.Join(", ", offenders));
+ }
+
+ ///
+ /// THE ONE LANE-SPECIFIC METHOD. The EditMode mirror resolves the same
+ /// file from Application.dataPath; here the anchor is the test binary
+ /// (the NoFloatInSimulationTests pattern).
+ ///
+ private static string ResolveMatchFingerprintSource()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory != null)
+ {
+ string scripts = Path.Combine(directory.FullName, "Assets", "_Project", "Scripts");
+ if (Directory.Exists(scripts))
+ {
+ return Path.Combine(scripts, "Simulation", "Replays", "MatchFingerprint.cs");
+ }
+ directory = directory.Parent;
+ }
+
+ Assert.Fail($"could not locate Assets/_Project/Scripts above {AppContext.BaseDirectory}");
+ return null;
+ }
+
[Test]
public void ComputeHash_AndEquality_AreSensitiveToEveryField()
{