acdream/tests/AcDream.App.Tests/Rendering/Walk/WalkOracleTrace.cs
Erik 02a8288172 feat(render): S3 chunk 1 — print-only walk transcript, OH fixtures, offline signature diff
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 <noreply@anthropic.com>
2026-09-03 11:49:42 +02:00

268 lines
11 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// Parser for the FW0 retail walk-oracle traces
/// (docs/research/2026-08-30-fw-walk-oracle/ — captured live from the
/// PDB-paired 2013 retail client on 2026-08-30; format documented in that
/// directory's README). These traces are Campaign FW's conformance
/// fixtures: FW1's RetailFrameWalk must reproduce each frame's event
/// sequence position-for-position.
///
/// The final frame of every capture is dropped: the harness detaches at
/// the frame MARKER, so the last frame never records its draws and would
/// read as a false "outdoor, no landscape" frame.
/// </summary>
public static class WalkOracleTrace
{
public static IReadOnlyList<WalkOracleFrame> Parse(IEnumerable<string> lines)
{
var frames = new List<WalkOracleFrame>();
List<WalkOracleEvent>? current = null;
int currentNumber = 0;
WalkOraclePose? currentPose = null;
foreach (string line in lines)
{
Match frameMatch = FramePattern.Match(line);
if (frameMatch.Success)
{
if (current is not null)
frames.Add(new WalkOracleFrame(currentNumber, current, currentPose));
currentNumber = int.Parse(
frameMatch.Groups[1].Value, CultureInfo.InvariantCulture);
current = new List<WalkOracleEvent>();
currentPose = null;
continue;
}
if (current is null)
continue;
Match poseMatch = PosePattern.Match(line);
if (poseMatch.Success)
{
// P <objcell_id> <origin xyz> <quat q0..q3> — raw IEEE-754 dwords
// dumped from Render::viewer_pos @0x0081ef00 at the frame marker.
uint[] raw = new uint[8];
for (int i = 0; i < 8; i++)
raw[i] = uint.Parse(
poseMatch.Groups[i + 1].Value,
NumberStyles.HexNumber, CultureInfo.InvariantCulture);
currentPose = new WalkOraclePose(
raw[0],
new System.Numerics.Vector3(
BitConverter.Int32BitsToSingle((int)raw[1]),
BitConverter.Int32BitsToSingle((int)raw[2]),
BitConverter.Int32BitsToSingle((int)raw[3])),
BitConverter.Int32BitsToSingle((int)raw[4]),
BitConverter.Int32BitsToSingle((int)raw[5]),
BitConverter.Int32BitsToSingle((int)raw[6]),
BitConverter.Int32BitsToSingle((int)raw[7]));
continue;
}
if (line == "LS")
{
current.Add(WalkOracleEvent.Landscape());
continue;
}
Match buildingMatch = BuildingPattern.Match(line);
if (buildingMatch.Success)
{
current.Add(WalkOracleEvent.Building(ParseId(buildingMatch.Groups[1].Value)));
continue;
}
Match insideMatch = DrawInsidePattern.Match(line);
if (insideMatch.Success)
{
current.Add(WalkOracleEvent.DrawInside(ParseId(insideMatch.Groups[1].Value)));
continue;
}
Match cellsMatch = DrawCellsPattern.Match(line);
if (cellsMatch.Success)
{
uint[] cells = cellsMatch.Groups[3].Value
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(ParseId)
.ToArray();
current.Add(WalkOracleEvent.DrawCells(
outsideViewCount: int.Parse(
cellsMatch.Groups[1].Value, CultureInfo.InvariantCulture),
declaredCount: int.Parse(
cellsMatch.Groups[2].Value, CultureInfo.InvariantCulture),
cells));
continue;
}
// S3 chunk 3 (§9.3 T1): LC/SC — RenderDeviceD3D::DrawLandCell
// 0x0059f120 / DrawSortCell 0x0059f140, added to the OH capture
// templates 2026-09-03 (tools/walk-oracle/oh/oh-capture-walk.cdb.template).
Match landCellMatch = LandCellPattern.Match(line);
if (landCellMatch.Success)
{
current.Add(WalkOracleEvent.LandCell(ParseId(landCellMatch.Groups[1].Value)));
continue;
}
Match sortCellMatch = SortCellPattern.Match(line);
if (sortCellMatch.Success)
{
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.
}
// The last STARTED frame (still in `current`) is deliberately never
// appended — that is the truncated detach frame (see class doc).
return frames;
}
public static IReadOnlyList<WalkOracleFrame> Load(string fixtureName)
=> Load("docs/research/2026-08-30-fw-walk-oracle", fixtureName);
/// <summary>S3 chunk 3 (§9.3 T1): loads a fixture from an arbitrary
/// repo-relative <paramref name="root"/> — the OH captures live under
/// <c>docs/research/2026-09-01-overhaul/oh-capture/</c>, a different
/// directory than the FW0 still fixtures the single-argument overload
/// defaults to.</summary>
public static IReadOnlyList<WalkOracleFrame> Load(string root, string fixtureName)
{
string repoRoot = FindRepositoryRoot();
string path = Path.Combine(
repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log");
return Parse(File.ReadLines(path));
}
private static string FindRepositoryRoot()
{
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.");
}
private static uint ParseId(string hex)
=> uint.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled);
private static readonly Regex PosePattern = new(
@"^P ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8}) ([0-9a-f]{8})\s*$",
RegexOptions.Compiled);
private static readonly Regex BuildingPattern = new(@"^BLD ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
private static readonly Regex DrawInsidePattern = new(@"^DI ([0-9a-f]{8})\s*$", RegexOptions.Compiled);
private static readonly Regex DrawCellsPattern = new(
@"^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);
}
/// <summary>The camera pose dumped at the frame marker (raw dwords from
/// <c>Render::viewer_pos</c> @0x0081ef00): the camera's cell id, world
/// origin, and the Frame quaternion's four raw components (q0..q3 in
/// storage order — axis convention resolved by the replay runner).
/// Present only in the pose-stamped capture round.</summary>
public sealed record WalkOraclePose(
uint CellId,
System.Numerics.Vector3 Origin,
float Q0,
float Q1,
float Q2,
float Q3);
public sealed record WalkOracleFrame(
int Number,
IReadOnlyList<WalkOracleEvent> Events,
WalkOraclePose? Pose = null)
{
/// <summary>The frame's root: the first DrawInside cell, or null when outdoor-rooted.</summary>
public uint? InteriorRootCell
=> Events.FirstOrDefault(e => e.Kind == WalkOracleEventKind.DrawInside)?.CellId;
public bool HasLandscape => Events.Any(e => e.Kind == WalkOracleEventKind.Landscape);
public IEnumerable<uint> Buildings
=> Events.Where(e => e.Kind == WalkOracleEventKind.Building).Select(e => e.CellId!.Value);
}
public enum WalkOracleEventKind
{
Landscape,
Building,
DrawInside,
DrawCells,
/// <summary>S3 chunk 3: <c>LC &lt;cellid&gt;</c> — <c>RenderDeviceD3D::
/// DrawLandCell</c> @0x0059f120 entry.</summary>
LandCell,
/// <summary>S3 chunk 3: <c>SC &lt;cellid&gt;</c> — <c>RenderDeviceD3D::
/// DrawSortCell</c> @0x0059f140 entry.</summary>
SortCell,
/// <summary>S3 chunk 1 (§11.2 B2): <c>EC &lt;cellid&gt;</c> —
/// <c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 entry (one cell
/// SHELL draw).</summary>
EnvCellShell,
/// <summary>S3 chunk 1 (§11.2 B2): <c>OC &lt;cellid&gt;</c> —
/// <c>RenderDeviceD3D::DrawObjCellForDummies</c> @0x005a0760 entry (one
/// cell OBJECT-LIST turn).</summary>
ObjectCellTurn,
}
public sealed record WalkOracleEvent(
WalkOracleEventKind Kind,
uint? CellId,
int OutsideViewCount,
int DeclaredCellCount,
IReadOnlyList<uint> Cells)
{
public static WalkOracleEvent Landscape()
=> new(WalkOracleEventKind.Landscape, null, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent Building(uint cellId)
=> new(WalkOracleEventKind.Building, cellId, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent DrawInside(uint cellId)
=> new(WalkOracleEventKind.DrawInside, cellId, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent DrawCells(
int outsideViewCount, int declaredCount, IReadOnlyList<uint> cells)
=> new(WalkOracleEventKind.DrawCells, null, outsideViewCount, declaredCount, cells);
public static WalkOracleEvent LandCell(uint cellId)
=> new(WalkOracleEventKind.LandCell, cellId, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent SortCell(uint cellId)
=> new(WalkOracleEventKind.SortCell, cellId, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent EnvCellShell(uint cellId)
=> new(WalkOracleEventKind.EnvCellShell, cellId, 0, 0, Array.Empty<uint>());
public static WalkOracleEvent ObjectCellTurn(uint cellId)
=> new(WalkOracleEventKind.ObjectCellTurn, cellId, 0, 0, Array.Empty<uint>());
}