From 02a8288172a99f1f5d543a1787f18501c0f168a7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 3 Sep 2026 10:48:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20S3=20chunk=201=20=E2=80=94=20pr?= =?UTF-8?q?int-only=20walk=20transcript,=20OH=20fixtures,=20offline=20sign?= =?UTF-8?q?ature=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign OVERHAUL S3 chunk 1 (docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md §11): the transcript-kinds/fixtures/print-only-emitter half of the walk work, built AFTER chunk 3 landed LC/SC (the per-land-cell interleave). B1 — the emitter (print-only, never gates admission/depth/order): - ACDREAM_DUMP_WALK_TRANSCRIPT=1 is read once into RuntimeOptions.DumpWalkTranscript (rule 4) and handed to RenderingDiagnostics.DumpWalkTranscriptEnabled (rule 5, a settable static, not a second env read) once at GameWindow construction — the deep walk call sites have no reachable RuntimeOptions reference. - WalkTranscriptDump (new) prints the OH line kinds — F/P/LS/LC/SC/BLD/DI/DC/EC/OC — to Console at the exact points retail's cdb breakpoints sit (tools/walk-oracle/oh/oh-capture-walk.cdb.template), gated internally so every method bails out before any string work when the flag is off. - Every call site lives in WalkFrameDriver.cs, at the point the driver already processes that turn: Collect (F/P, after BeginFrame), Emit's DI/LS/DC/BLD cases, OnLandCellTurn/OnLandscapeCellTurn (LC/SC, at LOD resolution via the new WalkTranscriptDump.LodCellId helper, before the 8x8-bucket expansion), and the EmitFloodTurns/EmitCellContentsTurn loops (EC/OC — both UNCONDITIONAL per flood visit, matching the OH captures' always-equal EC/OC counts; retail's own DrawEnvCell stamp dedupe sits past the breakpoint, inside the function). - DC's "pv=" field encodes interior(0)/outdoor(1) as an 8-hex-digit 0/1 so it satisfies the same pv=[0-9a-f]{8} regex real captures use; derived from _currentDcStage at the DC event (CellStatic = interior pview, else outdoor). - The frame-root pose (origin, quaternion) is a reasonable orthonormal basis built from the walk's own CyPlane.Normal forward vector and WalkLandscape's own ViewerWorldOriginX/Y block origin — self-consistent for the round-trip parser, not a byte-exact reproduction of retail's Frame (B4's diff never compares P). B2 — WalkOracleTrace learns EC/OC event kinds (LC/SC already existed from chunk 3). New WalkOraclePartsTrace.cs holds two small read-only parsers for the parts log (PD/DM) and the alpha-depth log (AM/FL/PM/PC) — records only, no validator, no canonical JSONL, no new tool. B3 — fixtures: the five OH walk captures join WalkOracleTraceTests.AllFixtures (now (root, name) pairs — FW0's own root plus the OH capture directory) for parse + complete-frame pins. The four kit poses join WalkTraceConformanceTests' still-fixture coverage as NEW rows (the OH cathedral-arrival root is f4180108, not FW0's f4180106): terrace-edge/cathedral-arrival extend the existing theory (now (root, fixture) parameterized); holtburg-doorway-still and foundry-deep get dedicated tests mirroring their FW0 siblings' own structure. Finding: the OH foundry-deep capture's own retail transcript draws 12 real town buildings through its exit chain (unlike the FW0 capture, which apparently reached none at that pose) — the FW0 test's stub 1x1 landscape undershoots it (first divergence: nothing after "LS" vs retail's real BLD content); fixed by building the full landscape/building world via WalkLandscapeDatBuilder.Build, matching the shared theory's own approach, not by skipping or weakening the row. WalkTraceReplayContext.Signature(WalkOracleFrame) now filters to the DI/DC/BLD/LS kinds (LC/SC/EC/OC never had a WalkEvent analogue in RetailFrameWalk's own four-kind vocabulary) instead of mapping them to a "?" placeholder, so the still- fixture comparison stays apples-to-apples on both sides. B4 — WalkTranscriptSignatureDiff (test-side only, no runner/tool): diffs two transcripts (raw lines or parsed frames) at the full DI/DC/BLD/LS/LC/SC/EC/OC level, reporting the first divergent event and position per frame. Proven over a synthetic self-vs-self-minus-one-event pair (SignatureDiff_ReportsTheExactRemovedEvent). Tests: T1 (flag off) is split into a unit-level zero-allocation/zero-output check on WalkTranscriptDump itself (the walk's pre-existing allocation, e.g. RetailFrameWalk.EmitDrawCells's per-call array, is untouched by this chunk and not independently zero-alloc) and an integration-level Collect() check; both assert via WalkOracleTrace.Parse returning zero frames rather than raw string equality, which is robust to unrelated Console.WriteLine noise from other test classes running in parallel (a real, observed hazard — WalkFrameDriverTests joins CameraDiagnosticsCollection for the same reason CornerFloodReplayTests/ Issue181WallPressEquilibriumTests already do, issue #251). T2 proves the emitter/parser round trip on a synthetic interior frame. T3's InstalledDat rows all pass. T4: LaunchOptionsDocumentationTests green with the ACDREAM_DUMP_WALK_TRANSCRIPT row (both directions). Gates: hermetic lane 6,814/0 (was 6,795 baseline + new tests), three consecutive clean runs; InstalledDat lane 245/3 known-failures (the two pre-existing #383 layout tests + TowerAscent) unchanged from baseline. Co-Authored-By: Claude Fable 5.1 --- docs/launch-options.md | 1 + src/AcDream.App/Rendering/GameWindow.cs | 7 + .../Rendering/Walk/WalkFrameDriver.cs | 82 +++++ .../Rendering/Walk/WalkTranscriptDump.cs | 191 ++++++++++ src/AcDream.App/RuntimeOptions.cs | 10 + .../Rendering/RenderingDiagnostics.cs | 26 ++ .../Rendering/CameraDiagnosticsCollection.cs | 6 +- .../Rendering/Walk/WalkFrameDriverTests.cs | 2 +- .../Walk/WalkFrameDriverTranscriptTests.cs | 344 ++++++++++++++++++ .../Rendering/Walk/WalkOraclePartsTrace.cs | 273 ++++++++++++++ .../Walk/WalkOraclePartsTraceTests.cs | 51 +++ .../Rendering/Walk/WalkOracleTrace.cs | 34 ++ .../Rendering/Walk/WalkOracleTraceTests.cs | 44 ++- .../Walk/WalkTraceConformanceTests.cs | 100 ++++- .../Rendering/Walk/WalkTraceReplay.cs | 20 +- .../Walk/WalkTranscriptSignatureDiff.cs | 93 +++++ .../AcDream.App.Tests/RuntimeOptionsTests.cs | 5 + 17 files changed, 1267 insertions(+), 22 deletions(-) create mode 100644 src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTrace.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTraceTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkTranscriptSignatureDiff.cs diff --git a/docs/launch-options.md b/docs/launch-options.md index 57315942..dd4c1447 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -272,6 +272,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_DUMP_GFXOBJS_DIR` | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | | `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | | `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` | +| `ACDREAM_DUMP_WALK_TRANSCRIPT` | `=1` | Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the production frame walk (`RetailFrameWalk` + `WalkFrameDriver`) prints the OH oracle-trace line kinds — `F`/`P`/`LS`/`LC`/`SC`/`BLD`/`DI`/`DC`/`EC`/`OC` — to `Console.Out` at the exact points retail's cdb breakpoints sit (`tools/walk-oracle/oh/oh-capture-walk.cdb.template`), so a Release run's own transcript can be diffed offline against a live retail capture (frame-by-frame, at the DI/DC/BLD/LS/LC/SC/EC/OC level — the test-side `WalkTranscriptSignatureDiff` helper does the diff, not this flag). | print-only; every print sits AFTER the walk has already decided to emit the corresponding turn — never gates admission, depth state, or draw order. Off, the emitter (`WalkTranscriptDump`) does no string work and allocates nothing (`WalkFrameDriverTests.TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp`). | off/unset | `RuntimeOptions.DumpWalkTranscript` (typed, sole env read) → `GameWindow.cs` (one-time handoff at construction) → `RenderingDiagnostics.DumpWalkTranscriptEnabled` (the actual gate every call site checks) → `WalkTranscriptDump` / `WalkFrameDriver.cs` | | `ACDREAM_HIDE_PART` | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | | `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 6fba8f40..a3f8e1c8 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -693,6 +693,13 @@ public sealed class GameWindow : AcDream.App.Plugins.BufferedRenderPackRegistry? renderPackRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + // Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the one-time RuntimeOptions + // (rule 4) → RenderingDiagnostics (rule 5) handoff for the print-only + // walk-transcript emitter — see RuntimeOptions.DumpWalkTranscript's + // own doc comment for why the deep walk call sites read the static + // property instead of this typed option directly. + AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled = + options.DumpWalkTranscript; _automation = automation; _statusWriter = new SessionStatusWriter(options.StatusFilePath); _platformServices = platformServices diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 4ba65a75..ee5520d0 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -713,6 +713,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private int _cellViewRouteIndex; private int _landscapeViewRouteIndex; + /// S3 chunk 1 (§11.2 B1): the "F n" transcript frame counter — + /// monotonic across this driver's whole lifetime (never reset by + /// BeginFrame/AbortFrame/EndFrame), matching a live cdb capture's own + /// per-process frame counter. Only ever incremented when the transcript + /// flag is on, so it costs nothing while off. + private int _transcriptFrameNumber; + internal WalkFrameDriver( WbDrawDispatcher dispatcher, IWalkFrameLeafRenderer leafRenderer, @@ -849,6 +856,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource ArgumentNullException.ThrowIfNull(ctx); BeginFrame(ctx, viewProjection, cameraWorldPosition); + // S3 chunk 1 (§11.2 B1): the frame-root "F n" + "P …" print — AFTER + // BeginFrame (a scratch reset, not an admission decision), BEFORE + // the walk itself runs, matching retail's SmartBox::RenderNormalMode + // @0x00453aa0 breakpoint (entered once per frame, before either the + // outdoor or the indoor branch). Every cost here (the counter + // increment, the landblock-local subtraction) sits inside the flag + // check, so an ordinary launch pays nothing. + if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled) + { + _transcriptFrameNumber++; + WalkTranscriptDump.PrintFrameRoot( + _transcriptFrameNumber, + cameraCellId, + cameraWorldPosition - new Vector3( + landscape.ViewerWorldOriginX, landscape.ViewerWorldOriginY, 0f), + ctx.CyPlane.Normal); + } try { walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this); @@ -1295,18 +1319,43 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource switch (walkEvent.Kind) { case WalkEventKind.DrawInside: + // S3 chunk 1 (§11.2 B1): PView::DrawInside @0x005a5860 entry. + WalkTranscriptDump.PrintDrawInside(walkEvent.CellId); _currentDcStage = WalkDrawStage.CellStatic; VisitedCells.Add(walkEvent.CellId); break; case WalkEventKind.Landscape: + // S3 chunk 1 (§11.2 B1): LScape::draw @0x00506330 entry — + // printed before the driver's own duplicate-turn guard so a + // desynced second LS still shows up in the transcript + // (the guard is a walk/driver fail-loud check, not a retail + // admission decision retail's own breakpoint would skip). + WalkTranscriptDump.PrintLandscape(); HandleLandscapeTurn(walkEvent.OutsideViewCount); break; case WalkEventKind.DrawCells: + // S3 chunk 1 (§11.2 B1): PView::DrawCells @0x005a4840 entry + // (breakpoint-entry order, matching the FW0/OH captures). + // _currentDcStage at THIS point (set by the immediately + // preceding DI or OnBuildingTurn event, never mutated by + // this case itself) tells us which PView owns this flood: + // CellStatic == the interior root's own flood (interior + // pview); anything else (LookInStatic) == a building + // look-in through the outdoor pview. + WalkTranscriptDump.PrintDrawCells( + outdoorPview: _currentDcStage == WalkDrawStage.LookInStatic, + walkEvent.OutsideViewCount, + walkEvent.Cells); foreach (uint id in walkEvent.Cells) VisitedCells.Add(id); HandleDrawCellsTurn(walkEvent.Cells); break; case WalkEventKind.Building: + // S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawBuilding + // @0x0059f2a0 entry — unconditional, matching the BLD event's + // own "fires at ENTRY, before the degrade check" contract + // (this type's class doc). + WalkTranscriptDump.PrintBuilding(walkEvent.CellId); // OnBuildingTurn (below) carries the actual side effects — // this vocabulary-only event needs no driver action. break; @@ -1331,6 +1380,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount)) throw new ArgumentOutOfRangeException(nameof(cellIndex)); + // S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawLandCell @0x0059f120 + // entry — one LC line per admitted LOD land cell, at LOD resolution + // (never expanded to the 8x8 owner-bucket ids OnLandscapeCellTurn + // uses for object-list content). + WalkTranscriptDump.PrintLandCell( + WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex)); + // The one mark rule (this type's own doc comment): flush any // already-queued stream content ahead of this leaf-renderer event. MarkIfGrown(); @@ -1355,6 +1411,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount)) throw new ArgumentOutOfRangeException(nameof(cellIndex)); + // S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawSortCell @0x0059f140 + // entry — ONE SC line per LOD cell (printed before the 8x8-bucket + // expansion below, which is an acdream object-list-content detail + // with no retail-breakpoint analogue of its own). + WalkTranscriptDump.PrintSortCell( + WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex)); + uint blockPrefix = landblockId & 0xFFFF0000u; if (sideCellCount == 8) { @@ -1675,6 +1738,17 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource { int viewRouteIndex = CaptureCellViewRoute(cells[i]); _floodViewRouteScratch[i] = viewRouteIndex; + // S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawEnvCell + // @0x0059f170 entry — UNCONDITIONAL, before the per-render-stamp + // dedupe below. Retail's own stamp check (GetDrawnThisFrame/ + // SetDrawnThisFrame) sits INSIDE DrawEnvCell, past this + // breakpoint's address, so every flood visit prints an EC line + // even when the visit's actual shell submission is suppressed — + // the OH captures' EC and OC counts are always exactly equal per + // pose (e.g. 45/45 at holtburg-doorway-still), which only holds + // if EC is visit-scoped like OC, not stamp-deduped like the + // WalkFrameEvent.CellShell submission below. + WalkTranscriptDump.PrintEnvCellShell(cells[i]); if (_cellShellsDrawnThisFrame.Add(cells[i])) { bool skipSouthLookInShell = @@ -1712,6 +1786,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource uint cellId, int viewRouteIndex) { + // S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawObjCellForDummies + // @0x005a0760 entry — the interior/look-in object-list turn (retail's + // outdoor DrawObjCell is a different function, folded into the SC + // line's own DrawSortCell call). Unconditional, matching this loop's + // existing no-dedupe shape (every flood visit already calls this + // method once, with no per-stamp gate). + WalkTranscriptDump.PrintObjectCellTurn(cellId); + // The shell loop captured the exact live portal_view for this cell. // Static and dynamic object lists reuse that same route: retail's // DrawEnvCell and DrawObjCell consume the same PortalList. diff --git a/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs b/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs new file mode 100644 index 00000000..f92c109a --- /dev/null +++ b/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs @@ -0,0 +1,191 @@ +using System.Numerics; +using System.Text; + +namespace AcDream.App.Rendering.Walk; + +/// +/// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the print-only walk-transcript +/// emitter. Prints the OH oracle-trace line kinds — F/P/ +/// LS/LC/SC/BLD/DI/DC/EC/ +/// OC — to at the EXACT points retail's cdb +/// breakpoints sit +/// (tools/walk-oracle/oh/oh-capture-walk.cdb.template), so a Release +/// run's output can be diffed offline against a live retail capture (§11.2 +/// B4). Gated by +/// +/// (rule 5) — every method here returns immediately, before any string +/// work, when the flag is off (T1: zero output, zero extra allocation on +/// the walk path). +/// +/// Every hex value prints lowercase, 8 digits +/// (value.ToString("x8")) to match WalkOracleTrace's +/// [0-9a-f]{8} regex family — an uppercase or short-padded print +/// silently fails to parse. +/// +/// This class never influences admission, depth state, or draw +/// order — every call site in invokes it +/// AFTER the walk has already decided to emit the corresponding turn. +/// +internal static class WalkTranscriptDump +{ + private static bool Enabled => + AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled; + + /// + /// The frame-root pair — "F n" then "P …" — printed once per + /// call (retail + /// SmartBox::RenderNormalMode @0x00453aa0). + /// is the walk's own CY-plane normal + /// (ctx.CyPlane.Normal) — the SAME forward direction the walk + /// already derived for this frame (WalkProductionFrameContext/ + /// WalkTraceReplayContext both store it there), so this costs no + /// extra camera-state lookup. The printed quaternion is a reasonable + /// orthonormal basis built FROM that forward vector (right = forward × + /// worldUp, up = right × forward) in retail's w,x,y,z storage order — an + /// internally self-consistent pose for the round-trip parser (T2), not a + /// byte-exact reproduction of retail's own internal Frame (the B4 + /// signature diff never compares the P line — see + /// WalkTraceReplayContext.Signature's DI/DC/BLD/LS/LC/SC/EC/OC + /// scope). is precomputed by the + /// caller from WalkLandscape.ViewerWorldOriginX/Y — the SAME 192 m + /// block origin the walk's own terrain/scenery placement already uses — + /// so this method does no coordinate math of its own. + /// + internal static void PrintFrameRoot( + int frameNumber, + uint cameraCellId, + Vector3 landblockLocalOrigin, + Vector3 forward) + { + if (!Enabled) return; + + // A forward nearly parallel to world-up degenerates the usual + // forward×worldUp cross product (an outdoor root looking straight + // down, or a synthetic test fixture); fall back to a different + // reference axis rather than emit a NaN/zero-length basis. + Vector3 worldUp = MathF.Abs(Vector3.Dot(forward, Vector3.UnitZ)) > 0.999f + ? Vector3.UnitY + : Vector3.UnitZ; + Vector3 right = Vector3.Normalize(Vector3.Cross(forward, worldUp)); + Vector3 up = Vector3.Cross(right, forward); + // Row-vector convention (System.Numerics: v' = v * M, so + // Vector3.Transform(axis, M) reads out ROW axis of M): row1→right + // (UnitX), row2→forward (UnitY), row3→up (UnitZ) — the SAME + // convention WalkTraceReplayContext decodes a P line with + // (forward = Transform(UnitY, rotation), up = Transform(UnitZ, …), + // right = Transform(UnitX, …)), so a self-produced P line round- + // trips through that SAME decoding, not just through the parser. + var basis = new Matrix4x4( + right.X, right.Y, right.Z, 0f, + forward.X, forward.Y, forward.Z, 0f, + up.X, up.Y, up.Z, 0f, + 0f, 0f, 0f, 1f); + Quaternion q = Quaternion.CreateFromRotationMatrix(basis); + + Console.WriteLine($"F {frameNumber}"); + Console.WriteLine( + $"P {cameraCellId.ToString("x8")} " + + $"{HexOf(landblockLocalOrigin.X)} {HexOf(landblockLocalOrigin.Y)} {HexOf(landblockLocalOrigin.Z)} " + + $"{HexOf(q.W)} {HexOf(q.X)} {HexOf(q.Y)} {HexOf(q.Z)}"); + } + + /// LScape::draw @0x00506330 entry. + internal static void PrintLandscape() + { + if (!Enabled) return; + Console.WriteLine("LS"); + } + + /// RenderDeviceD3D::DrawBuilding @0x0059f2a0 entry. + internal static void PrintBuilding(uint positionCellId) + { + if (!Enabled) return; + Console.WriteLine($"BLD {positionCellId.ToString("x8")}"); + } + + /// PView::DrawInside @0x005a5860 entry. + internal static void PrintDrawInside(uint cellId) + { + if (!Enabled) return; + Console.WriteLine($"DI {cellId.ToString("x8")}"); + } + + /// + /// PView::DrawCells @0x005a4840 entry. + /// is false for the interior pview's own root flood, true for a + /// building look-in through the outdoor pview (the caller derives this + /// from which stage is active at the DC event — see + /// WalkFrameDriver.Emit's DrawCells case). The "pv=" field + /// prints as an 8-hex-digit 0/1 (00000000/00000001) + /// rather than a real pointer so it satisfies the SAME + /// pv=[0-9a-f]{8} regex the retail captures use — the parser + /// ignores its value either way (§11.2 B1). + /// + internal static void PrintDrawCells( + bool outdoorPview, int outsideViewCount, IReadOnlyList cells) + { + if (!Enabled) return; + var sb = new StringBuilder(48 + cells.Count * 9); + sb.Append("DC pv=").Append(outdoorPview ? "00000001" : "00000000"); + sb.Append(" ov=").Append(outsideViewCount); + sb.Append(" n=").Append(cells.Count).Append(':'); + for (int i = 0; i < cells.Count; i++) + sb.Append(' ').Append(cells[i].ToString("x8")); + Console.WriteLine(sb.ToString()); + } + + /// RenderDeviceD3D::DrawLandCell @0x0059f120 entry. + internal static void PrintLandCell(uint cellId) + { + if (!Enabled) return; + Console.WriteLine($"LC {cellId.ToString("x8")}"); + } + + /// RenderDeviceD3D::DrawSortCell @0x0059f140 entry. + internal static void PrintSortCell(uint cellId) + { + if (!Enabled) return; + Console.WriteLine($"SC {cellId.ToString("x8")}"); + } + + /// RenderDeviceD3D::DrawEnvCell @0x0059f170 entry — fires + /// once per (flood, cell) VISIT, not once per render stamp: the retail + /// captures' EC and OC counts are always exactly equal per pose (e.g. + /// 45/45 at holtburg-doorway-still), which only holds if EC — like OC — + /// is unconditional at every flood visit, with the per-stamp dedupe + /// happening INSIDE DrawEnvCell, after this breakpoint's address, not in + /// the calling loop. + internal static void PrintEnvCellShell(uint cellId) + { + if (!Enabled) return; + Console.WriteLine($"EC {cellId.ToString("x8")}"); + } + + /// RenderDeviceD3D::DrawObjCellForDummies @0x005a0760 + /// entry — the interior/look-in object-list turn (retail's outdoor + /// DrawObjCell is a different function, folded into the "SC" + /// line's own DrawSortCell call; it has no separate OC hook). + internal static void PrintObjectCellTurn(uint cellId) + { + if (!Enabled) return; + Console.WriteLine($"OC {cellId.ToString("x8")}"); + } + + /// S3 §9.1 R2's LOD cell id: x*8+y+1 in LOD coordinates + /// (a side-2 block's cells are 1, 2, 9, 10), composed onto the + /// landblock prefix — the SAME id + /// WalkFrameDriver's OnLandscapeCellTurn(landblockId,8,cellIndex) + /// fast path already produces for side==8, generalized to every LOD. + /// / travel + /// PACKED (LOD resolution), matching OnLandCellTurn's own + /// contract — never the expanded 8×8-bucket resolution. + internal static uint LodCellId(uint landblockId, int sideCellCount, int cellIndex) + { + int x = cellIndex / sideCellCount; + int y = cellIndex % sideCellCount; + return (landblockId & 0xFFFF0000u) | checked((uint)(x * 8 + y + 1)); + } + + private static string HexOf(float value) + => unchecked((uint)BitConverter.SingleToInt32Bits(value)).ToString("x8"); +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index fa0ad2b0..ea094108 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -48,6 +48,15 @@ public sealed record RuntimeOptions( /// production input produced outbound movement traffic. bool DumpMoveTruth, bool DumpSky, + /// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the sole + /// authoritative read of ACDREAM_DUMP_WALK_TRANSCRIPT (rule 4). + /// GameWindow's constructor assigns this into + /// RenderingDiagnostics.DumpWalkTranscriptEnabled (rule 5) once + /// at startup — the deep walk call sites (WalkFrameDriver, + /// WalkTranscriptDump) check that static property, not this + /// field, since they have no reachable + /// reference. + bool DumpWalkTranscript, bool NoAudio, int HidePartIndex, bool RetailCloseDegrades, @@ -163,6 +172,7 @@ public sealed record RuntimeOptions( UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")), DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")), DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")), + DumpWalkTranscript: IsExactlyOne(env("ACDREAM_DUMP_WALK_TRANSCRIPT")), NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")), HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1, // Default-on: any value other than the literal string "0" enables diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 540e3f23..7d17aa72 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -670,4 +670,30 @@ public static class RenderingDiagnostics /// public static string? FrameHistoryPath { get; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY"); + + /// + /// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): print-only walk-transcript + /// emitter. When true, the production frame walk + /// (RetailFrameWalk + WalkFrameDriver, + /// src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs) prints + /// the OH oracle-trace line kinds — F/P/LS/LC/SC/BLD/DI/DC/EC/OC — to + /// Console at the exact points retail's cdb breakpoints sit + /// (tools/walk-oracle/oh/oh-capture-walk.cdb.template). Print- + /// only: it never gates admission, depth state, or draw order — every + /// print call sits AFTER the walk has already decided to emit the + /// corresponding turn, and every print call bails out before any string + /// work when this is false. + /// + /// This is the "owned by RenderingDiagnostics (rule 5)" half of §11.2 + /// B1's design; the sole authoritative read of + /// ACDREAM_DUMP_WALK_TRANSCRIPT lives in + /// RuntimeOptions.DumpWalkTranscript (rule 4) — this property + /// defaults false and is set exactly once, at GameWindow + /// construction, from that typed option (never reads the environment + /// directly itself, unlike this file's other flags), so the deep walk + /// call sites that have no reachable RuntimeOptions reference + /// still get one static, flippable-at-runtime gate to check. + /// + /// + public static bool DumpWalkTranscriptEnabled { get; set; } } diff --git a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs index 9f73f3cf..c9b04e0b 100644 --- a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs +++ b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs @@ -31,8 +31,12 @@ namespace AcDream.App.Tests.Rendering; /// AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled /// — written by CornerFloodReplayTests and /// Issue181WallPressEquilibriumTests. +/// AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled +/// (Campaign OVERHAUL S3 chunk 1) — written by +/// WalkFrameDriverTests' transcript-emitter tests +/// (WalkFrameDriverTranscriptTests.cs). /// System.Console.Out — redirected via -/// Console.SetOut by those same two classes to capture probe output. +/// Console.SetOut by those same classes to capture probe output. /// Interleaved redirection can restore a DISPOSED StringWriter as the /// process-wide Console.Out, which then throws in unrelated /// tests. diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index f31d4306..87455fbd 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -33,7 +33,7 @@ namespace AcDream.App.Tests.Rendering.Walk; /// still does not construct this driver); every world-data/leaf-renderer /// dependency here is a synthetic fake per plan §FW3.2b-1. /// -public sealed class WalkFrameDriverTests +public sealed partial class WalkFrameDriverTests { // ── Shared ordered log: BOTH the fake leaf renderer and the fake trace // write into ONE list, so a single sequence assertion proves the FULL diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs new file mode 100644 index 00000000..9055202b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs @@ -0,0 +1,344 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Walk; +using AcDream.App.Tests.Rendering; +using AcDream.Core.Rendering; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// Campaign OVERHAUL S3 chunk 1 (§11.2): tests T1/T2 for the print-only walk +/// transcript emitter (, gated by +/// ), plus the +/// §11.2 B4 offline signature-diff self-check. Reuses +/// 's own private fixture types via the +/// shared partial class — the SAME minimal interior two-cell flood +/// (one exit view) that file's own +/// RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells +/// test already exercises and proves correct; here only +/// runs (no Replay/GPU +/// submission), since every transcript print fires synchronously during the +/// walk itself. +/// +/// +/// and +/// are both process-wide mutable statics — joins +/// for the SAME reason +/// CornerFloodReplayTests/Issue181WallPressEquilibriumTests do +/// (that collection's own doc comment, issue #251): interleaved +/// Console.SetOut redirection across parallel test classes can +/// restore a disposed process-wide. +/// +/// +[Collection(CameraDiagnosticsCollection.Name)] +public sealed partial class WalkFrameDriverTests +{ + private static (WalkCell Cell1, WalkCell Cell2) BuildTranscriptFixtureCells(TestContext ctx) + { + var cell1 = new WalkCell + { + CellId = 0x100, + StabList = [0x101u], + Portals = + [ + new WalkCellPortal + { + OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0, + }, + // The exit portal — raises ov to 1, so the flood draws the + // (empty, zero-block) landscape too, exercising LS. + new WalkCellPortal + { + OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1, + }, + ], + PortalPolygons = [Quad(-2f), Quad(-3f)], + }; + var cell2 = new WalkCell + { + CellId = 0x101, + Portals = [new WalkCellPortal + { + OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0, + }], + PortalPolygons = [Quad(-2f)], + }; + ctx.Cells[cell1.CellId] = cell1; + ctx.Cells[cell2.CellId] = cell2; + return (cell1, cell2); + } + + /// T1 (§11.3): the flag off — zero output, and the emitter's + /// own methods (the only code this chunk adds to the walk path) + /// allocate nothing. The walk itself is NOT independently zero-alloc + /// (e.g. RetailFrameWalk.EmitDrawCells allocates its cell-id + /// array on every call — pre-existing, unrelated to this chunk), so the + /// allocation bound below is scoped to + /// itself, matching B1's actual contract ("allocates nothing EXTRA"). + [Fact] + public void TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp() + { + bool previous = RenderingDiagnostics.DumpWalkTranscriptEnabled; + RenderingDiagnostics.DumpWalkTranscriptEnabled = false; + TextWriter originalOut = Console.Out; + var capture = new StringWriter(); + try + { + Console.SetOut(capture); + + var cells = new List { 0x100u, 0x101u }; + void Step() + { + WalkTranscriptDump.PrintFrameRoot(1, 0x100u, Vector3.Zero, Vector3.UnitY); + WalkTranscriptDump.PrintLandscape(); + WalkTranscriptDump.PrintBuilding(0x100u); + WalkTranscriptDump.PrintDrawInside(0x100u); + WalkTranscriptDump.PrintDrawCells(outdoorPview: false, 1, cells); + WalkTranscriptDump.PrintLandCell(0xF4180001u); + WalkTranscriptDump.PrintSortCell(0xF4180001u); + WalkTranscriptDump.PrintEnvCellShell(0x101u); + WalkTranscriptDump.PrintObjectCellTurn(0x101u); + } + + ZeroAllocationProbe.AssertAllocatesNothing( + "WalkTranscriptDump.Print* (flag off)", Step); + + AssertNoTranscriptLines(capture); + } + finally + { + Console.SetOut(originalOut); + RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; + } + } + + /// T1's integration half: a full synthetic interior frame, + /// driven through the SAME path + /// production uses, produces literally NO transcript output when the + /// flag is off. + [Fact] + public void Collect_TranscriptFlagOff_ProducesNoConsoleOutput() + { + bool previous = RenderingDiagnostics.DumpWalkTranscriptEnabled; + RenderingDiagnostics.DumpWalkTranscriptEnabled = false; + TextWriter originalOut = Console.Out; + var capture = new StringWriter(); + try + { + Console.SetOut(capture); + + using var fx = new DispatcherFixture(); + var ctx = new TestContext(); + (WalkCell cell1, _) = BuildTranscriptFixtureCells(ctx); + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + var worldData = new FakeWorldData(); + var leaf = new RecordingLeafRenderer(new List()); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, clipFrame: clipFrame); + var walk = new RetailFrameWalk(); + + driver.Collect( + walk, cell1.CellId, cell1, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + + AssertNoTranscriptLines(capture); + } + finally + { + Console.SetOut(originalOut); + RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; + } + } + + /// + /// Asserts NONE of 's own line kinds + /// appear in — robust to unrelated + /// noise from another test class running in + /// parallel (a real, observed hazard: xUnit runs distinct classes + /// concurrently by default, and is a + /// process-wide static — see CameraDiagnosticsCollection's own + /// doc comment, issue #251). A plain Assert.Empty(capture.ToString()) + /// is NOT this robust: an unrelated class's unconditional + /// Console.WriteLine can land inside this test's redirect window + /// and fail it for a reason that has nothing to do with the transcript + /// flag. only ever appends a frame + /// once it sees a well-formed F <n> marker line — arbitrary + /// unrelated text can never satisfy that regex, so a truly EMPTY parsed + /// frame list is exactly as strong a proof that THIS code printed + /// nothing, without being sensitive to what else shares the process + /// console. + /// + private static void AssertNoTranscriptLines(StringWriter capture) + { + string[] lines = capture.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + Assert.Empty(WalkOracleTrace.Parse(lines)); + } + + /// T2 (§11.3): flag on, one synthetic interior frame — the + /// printed lines parse with the extended + /// into the same events the driver recorded (emitter/parser round + /// trip). Asserted structurally (kinds, cell ids, counts) rather than + /// against a hand-predicted exact ordering, so the test does not + /// silently pin an assumption about traversal order it never + /// independently verified. + [Fact] + public void Collect_TranscriptFlagOn_PrintedLinesRoundTripThroughTheParser() + { + bool previous = RenderingDiagnostics.DumpWalkTranscriptEnabled; + RenderingDiagnostics.DumpWalkTranscriptEnabled = true; + TextWriter originalOut = Console.Out; + var capture = new StringWriter(); + try + { + Console.SetOut(capture); + + using var fx = new DispatcherFixture(); + var ctx = new TestContext(); + (WalkCell cell1, WalkCell cell2) = BuildTranscriptFixtureCells(ctx); + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + var worldData = new FakeWorldData(); + var leaf = new RecordingLeafRenderer(new List()); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, clipFrame: clipFrame); + var walk = new RetailFrameWalk(); + + // Two Collect calls: WalkOracleTrace.Parse (like every real + // capture) only flushes a frame once the NEXT "F n" marker + // appears — it deliberately drops the final in-progress frame + // (the detach-frame rule). One frame alone would parse to zero + // complete frames. + driver.Collect( + walk, cell1.CellId, cell1, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + driver.Collect( + walk, cell1.CellId, cell1, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + + Console.Out.Flush(); + string[] lines = capture.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + Assert.NotEmpty(lines); + + IReadOnlyList frames = WalkOracleTrace.Parse(lines); + WalkOracleFrame frame = Assert.Single(frames); + Assert.Equal(1, frame.Number); + + Assert.NotNull(frame.Pose); + Assert.Equal(cell1.CellId, frame.Pose!.CellId); + + Assert.Equal(cell1.CellId, frame.InteriorRootCell); + Assert.True(frame.HasLandscape); + + WalkOracleEvent dc = Assert.Single( + frame.Events, e => e.Kind == WalkOracleEventKind.DrawCells); + Assert.Equal(1, dc.OutsideViewCount); + Assert.Equal( + new HashSet { cell1.CellId, cell2.CellId }, + dc.Cells.ToHashSet()); + + // The interior root's own flood visits both cells for a shell + // AND an object-list turn — EC/OC pair, one line per cell, no + // dedupe (WalkTranscriptDump.PrintEnvCellShell's own doc + // comment: EC/OC counts are always exactly equal per pose). + List ec = frame.Events + .Where(e => e.Kind == WalkOracleEventKind.EnvCellShell) + .Select(e => e.CellId!.Value).ToList(); + List oc = frame.Events + .Where(e => e.Kind == WalkOracleEventKind.ObjectCellTurn) + .Select(e => e.CellId!.Value).ToList(); + Assert.Equal(new HashSet { cell1.CellId, cell2.CellId }, ec.ToHashSet()); + Assert.Equal(new HashSet { cell1.CellId, cell2.CellId }, oc.ToHashSet()); + Assert.Equal(2, ec.Count); + Assert.Equal(2, oc.Count); + + // No landblocks were published (the stub 1x1 landscape), so no + // LC/SC/BLD turns exist this frame — the transcript format for + // those three kinds is proven separately, against real retail + // data, by WalkOracleTraceTests.AllFixtures and + // WalkLandCellOrderTests. + Assert.DoesNotContain(frame.Events, e => e.Kind + is WalkOracleEventKind.LandCell or WalkOracleEventKind.SortCell + or WalkOracleEventKind.Building); + } + finally + { + Console.SetOut(originalOut); + RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; + } + } + + /// §11.2 B4: the offline signature diff, self-checked over a + /// synthetic pair — the SAME captured T2 transcript versus itself with + /// its own LAST event removed. Reuses Collect_TranscriptFlagOn_…'s + /// own capture rather than duplicating the walk drive. + [Fact] + public void SignatureDiff_ReportsTheExactRemovedEvent() + { + bool previous = RenderingDiagnostics.DumpWalkTranscriptEnabled; + RenderingDiagnostics.DumpWalkTranscriptEnabled = true; + TextWriter originalOut = Console.Out; + var capture = new StringWriter(); + try + { + Console.SetOut(capture); + + using var fx = new DispatcherFixture(); + var ctx = new TestContext(); + (WalkCell cell1, _) = BuildTranscriptFixtureCells(ctx); + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + var worldData = new FakeWorldData(); + var leaf = new RecordingLeafRenderer(new List()); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, clipFrame: clipFrame); + var walk = new RetailFrameWalk(); + + // Two Collect calls — see the round-trip test's own comment on + // why one alone parses to zero complete frames. + driver.Collect( + walk, cell1.CellId, cell1, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + driver.Collect( + walk, cell1.CellId, cell1, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + + Console.Out.Flush(); + string[] lines = capture.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + + IReadOnlyList expected = WalkOracleTrace.Parse(lines); + WalkOracleFrame expectedFrame = Assert.Single(expected); + Assert.NotEmpty(expectedFrame.Events); + + // Self-vs-self (unmutated copy): no divergence at all. + IReadOnlyList unchanged = + [expectedFrame with { Events = expectedFrame.Events.ToList() }]; + Assert.Null(WalkTranscriptSignatureDiff.FirstDivergence(expected, unchanged)); + + // Self-vs-self-minus-one (the LAST event removed): the reported + // divergence is exact — same frame number, position exactly at + // the new (shorter) end, expected = the removed event's own + // signature string, actual = "". + int lastIndex = expectedFrame.Events.Count - 1; + List truncated = expectedFrame.Events.Take(lastIndex).ToList(); + IReadOnlyList actual = + [expectedFrame with { Events = truncated }]; + + WalkTranscriptSignatureDiff.Divergence? divergence = + WalkTranscriptSignatureDiff.FirstDivergence(expected, actual); + + Assert.NotNull(divergence); + Assert.Equal(expectedFrame.Number, divergence!.Value.FrameNumber); + Assert.Equal(lastIndex, divergence.Value.Position); + Assert.Equal("", divergence.Value.Actual); + Assert.NotEqual("", divergence.Value.Expected); + } + finally + { + Console.SetOut(originalOut); + RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTrace.cs new file mode 100644 index 00000000..57822b9e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTrace.cs @@ -0,0 +1,273 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S3 chunk 1 (§11.2 B2): small, read-only parsers for the OH capture +/// family's two companion logs — the PARTS log +/// (tools/walk-oracle/oh/oh-capture-parts.cdb.template: PD/DM) and +/// the ALPHA-DEPTH log +/// (tools/walk-oracle/oh/oh-capture-alpha-depth.cdb.template: AM/FL/ +/// PM/PC), both dumped from the SAME docs/research/2026-09-01-overhaul/ +/// oh-capture/ directory as the walk log +/// already parses. These are records only — no validator, no canonical +/// JSONL, no new tool ([[feedback-evidence-infrastructure-sink]]) — a later +/// chunk/S4 names the first real consumer and pins specific values then. +/// +public static class WalkOraclePartsTrace +{ + /// Parses a PARTS log (<pose>.parts.log). Same + /// F/P framing and truncated-last-frame drop rule as + /// — the harness detaches at the + /// frame marker, so the final "F n" never records its own PD/DM lines. + public static IReadOnlyList Parse(IEnumerable lines) + { + var frames = new List(); + List? partDraws = null; + List? meshDraws = null; + int currentNumber = 0; + + foreach (string line in lines) + { + Match frameMatch = FramePattern.Match(line); + if (frameMatch.Success) + { + if (partDraws is not null) + { + frames.Add(new WalkOraclePartsFrame(currentNumber, partDraws, meshDraws!)); + } + currentNumber = int.Parse( + frameMatch.Groups[1].Value, CultureInfo.InvariantCulture); + partDraws = new List(); + meshDraws = new List(); + continue; + } + if (partDraws is null) + continue; + + Match pd = PartDrawPattern.Match(line); + if (pd.Success) + { + partDraws.Add(new WalkOraclePartDraw( + ParseHex(pd.Groups[1].Value), + ParseHex(pd.Groups[2].Value), + pd.Groups[3].Value != "0", + ParseHex(pd.Groups[4].Value))); + continue; + } + Match dm = MeshDrawPattern.Match(line); + if (dm.Success) + { + meshDraws!.Add(new WalkOracleMeshDraw( + ParseHex(dm.Groups[1].Value), + ParseHex(dm.Groups[2].Value), + dm.Groups[3].Value != "0", + int.Parse(dm.Groups[4].Value, CultureInfo.InvariantCulture), + ParseHex(dm.Groups[5].Value))); + continue; + } + // Anything else (the "P …" pose line included — parts fixtures + // carry no pose consumer today) is ignored, matching + // WalkOracleTrace's own "cdb chrome" tolerance. + } + + return frames; + } + + public static IReadOnlyList Load(string root, string fixtureName) + => Parse(File.ReadLines(FixturePath(root, fixtureName, ".parts.log"))); + + private static string FixturePath(string root, string fixtureName, string suffix) + { + string repoRoot = WalkOracleTraceRepoRoot.Find(); + return Path.Combine( + repoRoot, Path.Combine(root.Split('/')), fixtureName + suffix); + } + + private static uint ParseHex(string hex) + => uint.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled); + + // PD gfx= did= force=<0|1> cell= + private static readonly Regex PartDrawPattern = new( + @"^PD gfx=([0-9a-f]{8}) did=([0-9a-f]{8}) force=(\d) cell=([0-9a-f]{8})\s*$", + RegexOptions.Compiled); + + // DM gfx= did= force=<0|1> bound=<0|1|2> cell= + private static readonly Regex MeshDrawPattern = new( + @"^DM gfx=([0-9a-f]{8}) did=([0-9a-f]{8}) force=(\d) bound=(\d) cell=([0-9a-f]{8})\s*$", + RegexOptions.Compiled); +} + +/// One CPhysicsPart::Draw @0x0050D7A0 entry (PD line). +public sealed record WalkOraclePartDraw(uint Gfx, uint DataId, bool Force, uint Cell); + +/// One RenderDeviceD3D::DrawMeshInternal @0x0059F360 entry +/// (DM line). is retail's BoundingType (0=OUTSIDE, +/// 1=PARTIALLY_INSIDE, 2=ENTIRELY_INSIDE). +public sealed record WalkOracleMeshDraw(uint Gfx, uint DataId, bool Force, int Bound, uint Cell); + +public sealed record WalkOraclePartsFrame( + int Number, + IReadOnlyList PartDraws, + IReadOnlyList MeshDraws); + +/// +/// S3 chunk 1 (§11.2 B2): parses an ALPHA-DEPTH log +/// (<pose>.alphadepth.log). Same F/P framing and +/// truncated-last-frame drop rule as . +/// +public static class WalkOracleAlphaDepthTrace +{ + public static IReadOnlyList Parse(IEnumerable lines) + { + var frames = new List(); + List? meshAdds = null; + List? flushes = null; + List? portalPolyDraws = null; + List? drawCellsSamples = null; + int currentNumber = 0; + + foreach (string line in lines) + { + Match frameMatch = FramePattern.Match(line); + if (frameMatch.Success) + { + if (meshAdds is not null) + { + frames.Add(new WalkOracleAlphaDepthFrame( + currentNumber, meshAdds, flushes!, portalPolyDraws!, drawCellsSamples!)); + } + currentNumber = int.Parse( + frameMatch.Groups[1].Value, CultureInfo.InvariantCulture); + meshAdds = new List(); + flushes = new List(); + portalPolyDraws = new List(); + drawCellsSamples = new List(); + continue; + } + if (meshAdds is null) + continue; + + Match am = MeshAddPattern.Match(line); + if (am.Success) + { + meshAdds.Add(new WalkOracleAlphaMeshAdd( + ParseHex(am.Groups[1].Value), + int.Parse(am.Groups[2].Value, CultureInfo.InvariantCulture), + ParseHex(am.Groups[3].Value), + am.Groups[4].Value != "0", + am.Groups[5].Value != "0", + int.Parse(am.Groups[6].Value, CultureInfo.InvariantCulture))); + continue; + } + Match fl = FlushPattern.Match(line); + if (fl.Success) + { + flushes!.Add(new WalkOracleAlphaFlush( + ParseHex(fl.Groups[1].Value), ParseHex(fl.Groups[2].Value))); + continue; + } + Match pm = PortalPolyPattern.Match(line); + if (pm.Success) + { + portalPolyDraws!.Add(new WalkOraclePortalPolyDraw( + ParseHex(pm.Groups[1].Value), + int.Parse(pm.Groups[2].Value, CultureInfo.InvariantCulture), + int.Parse(pm.Groups[3].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture))); + continue; + } + Match pc = DrawCellsSamplePattern.Match(line); + if (pc.Success) + { + drawCellsSamples!.Add(new WalkOracleDrawCellsSample( + int.Parse(pc.Groups[1].Value, CultureInfo.InvariantCulture), + int.Parse(pc.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture), + pc.Groups[3].Value != "0")); + continue; + } + } + + return frames; + } + + public static IReadOnlyList Load(string root, string fixtureName) + => Parse(File.ReadLines(Path.Combine( + WalkOracleTraceRepoRoot.Find(), + Path.Combine(root.Split('/')), + fixtureName + ".alphadepth.log"))); + + private static uint ParseHex(string hex) + => uint.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled); + + // AM mesh= surf= csurf= new=<0|1> clip=<0|1> listSel= + private static readonly Regex MeshAddPattern = new( + @"^AM mesh=([0-9a-f]{8}) surf=(\d+) csurf=([0-9a-f]{8}) new=(\d) clip=(\d) listSel=(\d+)\s*$", + RegexOptions.Compiled); + + // FL thresh= ret= + private static readonly Regex FlushPattern = new( + @"^FL thresh=([0-9a-f]{8}) ret=([0-9a-f]{8})\s*$", RegexOptions.Compiled); + + // PM poly= mode= counterBefore= + private static readonly Regex PortalPolyPattern = new( + @"^PM poly=([0-9a-f]{8}) mode=(\d) counterBefore=([0-9a-f]{4})\s*$", + RegexOptions.Compiled); + + // PC ov= counter= fc=<0|1> + private static readonly Regex DrawCellsSamplePattern = new( + @"^PC ov=(\d+) counter=([0-9a-f]{4}) fc=(\d)\s*$", RegexOptions.Compiled); +} + +/// One D3DPolyRender::AddMeshToAlphaList @0x0059C230 entry +/// (AM line). 0 selects the ALPHA list, +/// nonzero the CLIP list. +public sealed record WalkOracleAlphaMeshAdd( + uint Mesh, int Surface, uint ClipSurface, bool New, bool Clip, int ListSelector); + +/// One D3DPolyRender::FlushAlphaList @0x0059D2E0 entry (FL +/// line). is the raw IEEE-754 bits of the +/// threshold float argument. +public sealed record WalkOracleAlphaFlush(uint ThresholdBits, uint ReturnAddress); + +/// One D3DPolyRender::DrawPortalPolyInternal @0x0059BC90 +/// entry (PM line). 0 = true-depth/exit-seal, +/// nonzero = far-Z/building punch. +public sealed record WalkOraclePortalPolyDraw(uint Poly, int Mode, int CounterBefore); + +/// One PView::DrawCells @0x005A4840 entry, sampled for the +/// persistent portalsDrawnCount depth-lifecycle state machine (PC +/// line) — same breakpoint address as the walk log's DC line, but this is a +/// SEPARATE capture that does not also record the cell roster. +public sealed record WalkOracleDrawCellsSample(int OutsideViewCount, int Counter, bool ForceClear); + +public sealed record WalkOracleAlphaDepthFrame( + int Number, + IReadOnlyList MeshAdds, + IReadOnlyList Flushes, + IReadOnlyList PortalPolyDraws, + IReadOnlyList DrawCellsSamples); + +/// Shared repo-root finder — the SAME walk-up-to-AcDream.slnx +/// logic already has privately; factored out +/// so the parts/alpha-depth parsers don't duplicate it a second and third +/// time. +internal static class WalkOracleTraceRepoRoot +{ + internal static string Find() + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "AcDream.slnx"))) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException( + "AcDream.slnx not found above the test base directory; walk-oracle fixtures unavailable."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTraceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTraceTests.cs new file mode 100644 index 00000000..b3f51465 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkOraclePartsTraceTests.cs @@ -0,0 +1,51 @@ +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S3 chunk 1 (§11.2 B2/B3): proves the parts/alpha-depth readers actually +/// parse the five committed OH captures — "imported and parsed", not just +/// compiled. No content is pinned (§11.2 B3: "pinned only where a later +/// chunk/S4 names a consumer — no speculative assertions"); this is the +/// same non-empty/complete-frame shape +/// WalkOracleTraceTests.Fixture_parses_with_complete_frames already +/// applies to the walk logs. +/// +public sealed class WalkOraclePartsTraceTests +{ + private const string OhRoot = "docs/research/2026-09-01-overhaul/oh-capture"; + + public static readonly TheoryData AllPoses = new() + { + "holtburg-doorway-still", + "terrace-edge", + "cathedral-arrival", + "foundry-deep", + "cathedral-leak", + }; + + [Theory] + [MemberData(nameof(AllPoses))] + public void Parts_log_parses_with_nonempty_draws(string pose) + { + IReadOnlyList frames = WalkOraclePartsTrace.Load(OhRoot, pose); + + Assert.NotEmpty(frames); + Assert.Equal(1, frames[0].Number); + Assert.Equal(frames.Count, frames[^1].Number); + Assert.Contains(frames, f => f.PartDraws.Count > 0); + Assert.Contains(frames, f => f.MeshDraws.Count > 0); + } + + [Theory] + [MemberData(nameof(AllPoses))] + public void Alpha_depth_log_parses_with_nonempty_samples(string pose) + { + IReadOnlyList frames = + WalkOracleAlphaDepthTrace.Load(OhRoot, pose); + + Assert.NotEmpty(frames); + Assert.Equal(1, frames[0].Number); + Assert.Equal(frames.Count, frames[^1].Number); + Assert.Contains(frames, f => f.Flushes.Count > 0); + Assert.Contains(frames, f => f.DrawCellsSamples.Count > 0); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs index 9ee75af0..e71328cc 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs @@ -110,6 +110,22 @@ public static class WalkOracleTrace current.Add(WalkOracleEvent.SortCell(ParseId(sortCellMatch.Groups[1].Value))); continue; } + // S3 chunk 1 (§11.2 B2): EC/OC — RenderDeviceD3D::DrawEnvCell + // 0x0059f170 / DrawObjCellForDummies 0x005a0760, added to the OH + // capture templates 2026-09-02 + // (tools/walk-oracle/oh/oh-capture-walk.cdb.template). + Match envCellMatch = EnvCellShellPattern.Match(line); + if (envCellMatch.Success) + { + current.Add(WalkOracleEvent.EnvCellShell(ParseId(envCellMatch.Groups[1].Value))); + continue; + } + Match objCellMatch = ObjectCellTurnPattern.Match(line); + if (objCellMatch.Success) + { + current.Add(WalkOracleEvent.ObjectCellTurn(ParseId(objCellMatch.Groups[1].Value))); + continue; + } // Anything else is cdb chrome (banner, prompts, symbol notes) — ignored. } @@ -160,6 +176,8 @@ public static class WalkOracleTrace @"^DC pv=[0-9a-f]{8} ov=(\d+) n=(\d+):((?: [0-9a-f]{8})*)\s*$", RegexOptions.Compiled); private static readonly Regex LandCellPattern = new(@"^LC ([0-9a-f]{8})\s*$", RegexOptions.Compiled); private static readonly Regex SortCellPattern = new(@"^SC ([0-9a-f]{8})\s*$", RegexOptions.Compiled); + private static readonly Regex EnvCellShellPattern = new(@"^EC ([0-9a-f]{8})\s*$", RegexOptions.Compiled); + private static readonly Regex ObjectCellTurnPattern = new(@"^OC ([0-9a-f]{8})\s*$", RegexOptions.Compiled); } /// The camera pose dumped at the frame marker (raw dwords from @@ -204,6 +222,16 @@ public enum WalkOracleEventKind /// S3 chunk 3: SC <cellid>RenderDeviceD3D:: /// DrawSortCell @0x0059f140 entry. SortCell, + + /// S3 chunk 1 (§11.2 B2): EC <cellid> — + /// RenderDeviceD3D::DrawEnvCell @0x0059f170 entry (one cell + /// SHELL draw). + EnvCellShell, + + /// S3 chunk 1 (§11.2 B2): OC <cellid> — + /// RenderDeviceD3D::DrawObjCellForDummies @0x005a0760 entry (one + /// cell OBJECT-LIST turn). + ObjectCellTurn, } public sealed record WalkOracleEvent( @@ -231,4 +259,10 @@ public sealed record WalkOracleEvent( public static WalkOracleEvent SortCell(uint cellId) => new(WalkOracleEventKind.SortCell, cellId, 0, 0, Array.Empty()); + + public static WalkOracleEvent EnvCellShell(uint cellId) + => new(WalkOracleEventKind.EnvCellShell, cellId, 0, 0, Array.Empty()); + + public static WalkOracleEvent ObjectCellTurn(uint cellId) + => new(WalkOracleEventKind.ObjectCellTurn, cellId, 0, 0, Array.Empty()); } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTraceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTraceTests.cs index c066c4a0..69a0fa09 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTraceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTraceTests.cs @@ -10,25 +10,43 @@ public sealed class WalkOracleTraceTests { private const uint FarBuilding = 0xF518002Eu; - public static readonly TheoryData AllFixtures = new() + private const string FwRoot = "docs/research/2026-08-30-fw-walk-oracle"; + + /// S3 chunk 1 (§11.2 B3): the OH capture directory. Its walk + /// logs are named <pose>.walk.log — the trailing + /// .walk below is the fixture-name half of that filename, not a + /// subdirectory (matching 's own + /// convention). + private const string OhRoot = "docs/research/2026-09-01-overhaul/oh-capture"; + + /// (root, fixture name) pairs. Every FW0 still/posed fixture + /// keeps its old bare name under ; the five OH + /// walk captures (§11.2 B3) join under with the + /// .walk filename-half suffix. + public static readonly TheoryData AllFixtures = new() { - "terrace-center", - "terrace-edge", - "cathedral-arrival", - "holtburg-doorway-still", - "holtburg-walkout", - "holtburg-street-porchcam", - "holtburg-street-outdoor", - "holtburg-walkabout", - "foundry-entry", - "foundry-deep", + { FwRoot, "terrace-center" }, + { FwRoot, "terrace-edge" }, + { FwRoot, "cathedral-arrival" }, + { FwRoot, "holtburg-doorway-still" }, + { FwRoot, "holtburg-walkout" }, + { FwRoot, "holtburg-street-porchcam" }, + { FwRoot, "holtburg-street-outdoor" }, + { FwRoot, "holtburg-walkabout" }, + { FwRoot, "foundry-entry" }, + { FwRoot, "foundry-deep" }, + { OhRoot, "holtburg-doorway-still.walk" }, + { OhRoot, "terrace-edge.walk" }, + { OhRoot, "cathedral-arrival.walk" }, + { OhRoot, "foundry-deep.walk" }, + { OhRoot, "cathedral-leak.walk" }, }; [Theory] [MemberData(nameof(AllFixtures))] - public void Fixture_parses_with_complete_frames(string name) + public void Fixture_parses_with_complete_frames(string root, string name) { - IReadOnlyList frames = WalkOracleTrace.Load(name); + IReadOnlyList frames = WalkOracleTrace.Load(root, name); Assert.NotEmpty(frames); // Frame numbers are contiguous from 1; the truncated final frame is dropped. diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 3f3ba110..5ea2c414 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -100,13 +100,57 @@ public sealed class WalkTraceConformanceTests $"walk diverged from retail\nEXPECTED: {expected}\nACTUAL: {actual}"); } - [Theory] - [InlineData("posed/terrace-center")] - [InlineData("posed/terrace-edge")] - [InlineData("posed/cathedral-arrival")] - public void Still_fixture_first_frame_reproduces_exactly(string fixture) + [Fact] + public void Oh_doorway_still_first_frame_diff() { - IReadOnlyList frames = WalkOracleTrace.Load(fixture); + // S3 chunk 1 (§11.2 B3): the OH kit pose's own doorway-still capture + // — DI f4180108... no, a9b4013f, DC(ov=2, n=3) per §6b. Mirrors + // Doorway_still_first_frame_diff's own structure (this pose needs + // the interior camera cell, unlike the plain outdoor/theory rows). + IReadOnlyList frames = WalkOracleTrace.Load( + "docs/research/2026-09-01-overhaul/oh-capture", "holtburg-doorway-still.walk"); + Assert.NotEmpty(frames); + using DatCollection dats = OpenDats(); + WalkOracleFrame frame = frames[1]; + Assert.NotNull(frame.Pose); + WalkLandscapeDatBuilder.BuiltWorld world = + WalkLandscapeDatBuilder.Build(dats, frame.Pose!.CellId, frame.Pose.Origin); + var ctx = new WalkTraceReplayContext(frame.Pose, world.Cells) + { + Buildings = world.Buildings, + }; + WalkCell camera = Assert.Contains(frame.Pose.CellId, world.Cells); + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + + walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); + + string expected = WalkTraceReplayContext.Signature(frame); + string actual = WalkTraceReplayContext.Signature(recorder.Events); + Assert.True( + expected == actual, + $"walk diverged from retail\nEXPECTED: {expected}\nACTUAL: {actual}"); + } + + private const string FwOracleRoot = "docs/research/2026-08-30-fw-walk-oracle"; + + /// S3 chunk 1 (§11.2 B3): the OH capture directory — its OWN + /// pose-stamped kit-pose captures, a DIFFERENT root than the FW0 still + /// fixtures above. + private const string OhCaptureRoot = "docs/research/2026-09-01-overhaul/oh-capture"; + + [Theory] + [InlineData(FwOracleRoot, "posed/terrace-center")] + [InlineData(FwOracleRoot, "posed/terrace-edge")] + [InlineData(FwOracleRoot, "posed/cathedral-arrival")] + // S3 chunk 1 (§11.2 B3): the OH kit poses, as NEW rows — the OH + // cathedral-arrival root is f4180108, NOT FW0's f4180106 (a new pose, + // not a replacement of the FW0 row above). + [InlineData(OhCaptureRoot, "terrace-edge.walk")] + [InlineData(OhCaptureRoot, "cathedral-arrival.walk")] + public void Still_fixture_first_frame_reproduces_exactly(string root, string fixture) + { + IReadOnlyList frames = WalkOracleTrace.Load(root, fixture); Assert.NotEmpty(frames); using DatCollection dats = OpenDats(); WalkOracleFrame frame = frames[1]; @@ -240,4 +284,48 @@ public sealed class WalkTraceConformanceTests WalkTraceReplayContext.Signature(recorder.Events)); } } + + [Fact] + public void Oh_foundry_deep_reproduces_every_complete_frame_exactly() + { + // S3 chunk 1 (§11.2 B3): the OH kit pose's own foundry-deep capture + // — DI a9b40176, DC(ov=1, n=2), 12 town buildings drawn through the + // surviving exit chain (§6b). UNLIKE the FW0 sibling above, this + // capture's own retail transcript shows real BLD content at that + // depth, so the stub 1x1 landscape (which has no blocks/buildings to + // walk at all) undershoots it — first divergence, run without the + // fix below: "DI:a9b40176|DC:ov=1:a9b40176,a9b40177|LS" (nothing + // after LS) vs retail's "…|LS|BLD:a9b40031|BLD:a9b…" (12 real + // buildings). The full landscape/building assembler + // (WalkLandscapeDatBuilder.Build — the SAME one the shared theory + // and Oh_doorway_still_first_frame_diff use) reproduces them; the + // camera is stationary across this still pose, so one build serves + // every frame in the loop, matching the FW0 sibling's "build once" + // shape. + IReadOnlyList frames = WalkOracleTrace.Load( + "docs/research/2026-09-01-overhaul/oh-capture", "foundry-deep.walk"); + Assert.NotEmpty(frames); + using DatCollection dats = OpenDats(); + Assert.NotNull(frames[0].Pose); + WalkLandscapeDatBuilder.BuiltWorld world = WalkLandscapeDatBuilder.Build( + dats, frames[0].Pose!.CellId, frames[0].Pose!.Origin); + + foreach (WalkOracleFrame frame in frames) + { + Assert.NotNull(frame.Pose); + WalkCell camera = Assert.Contains(frame.Pose!.CellId, world.Cells); + var ctx = new WalkTraceReplayContext(frame.Pose, world.Cells) + { + Buildings = world.Buildings, + }; + var walk = new RetailFrameWalk(); + var recorder = new Recorder(); + + walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); + + Assert.Equal( + WalkTraceReplayContext.Signature(frame), + WalkTraceReplayContext.Signature(recorder.Events)); + } + } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs index 30763590..51077542 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs @@ -146,8 +146,26 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk _ => "?", })); + /// + /// S3 chunk 1 (§11.2 B3): the OH captures interleave LC/SC/ + /// EC/OC lines the pre-chunk-3 FW0 fixtures never had. + /// 's own WalkEvent vocabulary has + /// exactly four kinds (Landscape/Building/DrawInside/DrawCells) — LC/SC/ + /// EC/OC are separate hooks + /// never overrides, so the replay side of a + /// signature diff is silent on them by construction. Filtering them out + /// here (rather than mapping to a "?" placeholder) keeps this + /// comparison at the SAME DI/DC/BLD/LS level on both sides — S3's own + /// scope note ("no speculative pins" for LC/SC/EC/OC content) means this + /// method must not even attempt to compare them, not merely fail to. + /// public static string Signature(WalkOracleFrame frame) - => string.Join("|", frame.Events.Select(e => e.Kind switch + => string.Join("|", frame.Events + .Where(e => e.Kind is WalkOracleEventKind.Landscape + or WalkOracleEventKind.Building + or WalkOracleEventKind.DrawInside + or WalkOracleEventKind.DrawCells) + .Select(e => e.Kind switch { WalkOracleEventKind.Landscape => "LS", WalkOracleEventKind.Building => $"BLD:{e.CellId!.Value:x8}", diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTranscriptSignatureDiff.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTranscriptSignatureDiff.cs new file mode 100644 index 00000000..552cc683 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTranscriptSignatureDiff.cs @@ -0,0 +1,93 @@ +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S3 chunk 1 (§11.2 B4): the offline transcript signature diff. Given an +/// acdream transcript (printed by the production +/// ACDREAM_DUMP_WALK_TRANSCRIPT=1 emitter — WalkTranscriptDump) +/// and a retail capture (or any two -parsed +/// transcripts), reports the FIRST divergent event and its position, frame +/// by frame, at the DI/DC/BLD/LS/LC/SC/EC/OC level. A test-side comparison +/// utility only — no runner, no dashboard, no new tool under +/// tools/ ([[feedback-evidence-infrastructure-sink]]); the S3 review +/// and G3 consume its report by hand. +/// +public static class WalkTranscriptSignatureDiff +{ + /// The first point two transcripts disagree, or + /// when every frame both sides share matches + /// exactly. is the retail-side frame's + /// own number (matching the "F n" line); + /// is the zero-based index into that frame's DI/DC/BLD/LS/LC/SC/EC/OC + /// event signature. "<end of frame>"/"<missing + /// frame>" mark a length mismatch rather than a content + /// mismatch. + public readonly record struct Divergence( + int FrameNumber, int Position, string Expected, string Actual); + + /// Diffs two raw transcripts (e.g. one file read as lines each) + /// — the file-to-file form B4 names directly. + public static Divergence? FirstDivergence( + IEnumerable expectedLines, IEnumerable actualLines) + => FirstDivergence( + WalkOracleTrace.Parse(expectedLines), WalkOracleTrace.Parse(actualLines)); + + /// Diffs two already-parsed frame lists — the form the + /// synthetic self-test below uses directly, without a round trip + /// through a temp file. + public static Divergence? FirstDivergence( + IReadOnlyList expected, IReadOnlyList actual) + { + int frameCount = Math.Min(expected.Count, actual.Count); + for (int f = 0; f < frameCount; f++) + { + IReadOnlyList e = Signature(expected[f]); + IReadOnlyList a = Signature(actual[f]); + int shared = Math.Min(e.Count, a.Count); + for (int i = 0; i < shared; i++) + { + if (!string.Equals(e[i], a[i], StringComparison.Ordinal)) + return new Divergence(expected[f].Number, i, e[i], a[i]); + } + if (e.Count != a.Count) + { + return new Divergence( + expected[f].Number, + shared, + shared < e.Count ? e[shared] : "", + shared < a.Count ? a[shared] : ""); + } + } + if (expected.Count != actual.Count) + { + int frameNumber = frameCount < expected.Count + ? expected[frameCount].Number + : actual[frameCount].Number; + return new Divergence( + frameNumber, + 0, + frameCount < expected.Count ? "" : "", + frameCount < actual.Count ? "" : ""); + } + return null; + } + + /// One frame's DI/DC/BLD/LS/LC/SC/EC/OC turns, in transcript + /// order — the full eight-kind vocabulary B4 names (unlike + /// , + /// which deliberately stays at the pre-S3-chunk-3 four-kind level for + /// the B3 still-fixture rows). + private static IReadOnlyList Signature(WalkOracleFrame frame) + => frame.Events.Select(e => e.Kind switch + { + WalkOracleEventKind.Landscape => "LS", + WalkOracleEventKind.Building => $"BLD:{e.CellId!.Value:x8}", + WalkOracleEventKind.DrawInside => $"DI:{e.CellId!.Value:x8}", + WalkOracleEventKind.DrawCells => + $"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}", + WalkOracleEventKind.LandCell => $"LC:{e.CellId!.Value:x8}", + WalkOracleEventKind.SortCell => $"SC:{e.CellId!.Value:x8}", + WalkOracleEventKind.EnvCellShell => $"EC:{e.CellId!.Value:x8}", + WalkOracleEventKind.ObjectCellTurn => $"OC:{e.CellId!.Value:x8}", + _ => "?", + }).ToList(); +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 17477cf5..c5d3a39d 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -155,6 +155,7 @@ public sealed class RuntimeOptionsTests Assert.False(opts.DevTools); Assert.False(opts.UncappedRendering); Assert.False(opts.DumpMoveTruth); + Assert.False(opts.DumpWalkTranscript); Assert.False(opts.NoAudio); Assert.Equal(-1, opts.HidePartIndex); // Default-on: RetailCloseDegrades is true unless explicitly disabled. @@ -521,6 +522,7 @@ public sealed class RuntimeOptionsTests ["ACDREAM_UNCAPPED_RENDER"] = "1", ["ACDREAM_DUMP_MOVE_TRUTH"] = "1", ["ACDREAM_DUMP_SKY"] = "1", + ["ACDREAM_DUMP_WALK_TRANSCRIPT"] = "1", ["ACDREAM_NO_AUDIO"] = "1", ["ACDREAM_DUMP_SCENERY_Z"] = "1", })); @@ -528,6 +530,7 @@ public sealed class RuntimeOptionsTests Assert.True(allOn.UncappedRendering); Assert.True(allOn.DumpMoveTruth); Assert.True(allOn.DumpSky); + Assert.True(allOn.DumpWalkTranscript); Assert.True(allOn.NoAudio); Assert.True(allOn.DumpSceneryZ); @@ -540,12 +543,14 @@ public sealed class RuntimeOptionsTests ["ACDREAM_UNCAPPED_RENDER"] = "true", ["ACDREAM_NO_AUDIO"] = "2", ["ACDREAM_DUMP_SCENERY_Z"] = " 1", + ["ACDREAM_DUMP_WALK_TRANSCRIPT"] = "0", })); Assert.False(anyOther.DevTools); Assert.False(anyOther.UncappedRendering); Assert.False(anyOther.DumpMoveTruth); Assert.False(anyOther.NoAudio); Assert.False(anyOther.DumpSceneryZ); + Assert.False(anyOther.DumpWalkTranscript); } [Fact]