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]