From 45189a09720d76e8c5829d87e80f71102dd2d204 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 17:44:56 +0200 Subject: [PATCH 1/2] fix(replays): freeze rules revisions as literal rows (#138, D-120) ComputeRulesHash64(rev) promised to represent prior rules exactly, but only the field count was historical: every revision hashed today's constants. #131 (HQ ceiling 2000 -> 3000) therefore rewrote the identity of revisions 1 and 2 retroactively, and the "frozen" golden test could only ever pin the present. Historic revisions now carry literal rows (V1, V2, V3) in a marked source region; ComputeCurrentRulesHash64 is the only path that reads live constants, DescribeCurrentRulesDrift compares both field by field as a tripwire, and a source guard rejects live references inside the literal region. Revisions are append-only: a changed hashed constant means a new revision, never a re-pin. Fixes #138, refs #108. Decision D-120 (draft; binding with the merge). The current RulesHash64 stays 0xD1B683837D12FCEF; the revision 1/2 pins return to 0x531CE8F614A16CB5 / 0x07725EA26668C9F8. None of the four guard baselines is touched. Sim tests 781/781; Unity EditMode mirror not run. Co-Authored-By: Claude Fable 5.1 --- .../Simulation/MatchFingerprintV1Tests.cs | 106 ++++++- .../Simulation/Replays/MatchFingerprint.cs | 264 ++++++++++++++---- CHANGELOG.md | 9 + docs/production/DecisionLog.md | 108 +++++++ .../01-issue-138-rules-revisions-frozen.md | 122 ++++++++ .../01b-review-1-issue-138.md | 182 ++++++++++++ .../MatchFingerprintTests.cs | 115 +++++++- 7 files changed, 836 insertions(+), 70 deletions(-) create mode 100644 reports/v8.6.0/issue-lauf-2026-10-17/01-issue-138-rules-revisions-frozen.md create mode 100644 reports/v8.6.0/issue-lauf-2026-10-17/01b-review-1-issue-138.md 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..2325a4e4 --- /dev/null +++ b/reports/v8.6.0/issue-lauf-2026-10-17/01-issue-138-rules-revisions-frozen.md @@ -0,0 +1,122 @@ +--- +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 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() { From 24bf76a38f40c2c5bafb14626b87ce4ac970cc31 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 18:05:36 +0200 Subject: [PATCH 2/2] docs(report): record the Unity lane results for this package EditMode 666/666 and PlayMode 15/15 measured on this branch with Unity 6000.5.4f1 via the lane script from #110; no failures, clean tree. Co-Authored-By: Claude Fable 5.1 --- .../01-issue-138-rules-revisions-frozen.md | 4 ++++ 1 file changed, 4 insertions(+) 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 index 2325a4e4..852867a3 100644 --- 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 @@ -120,3 +120,7 @@ Landet der Art-Strang zuerst, zieht dieses Paket nach. Dann friert es zusätzlic > **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.