acdream/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
Erik 88c70072e0 fix(render): S3 chunk 1 round 2 — one weather OC per frame (pinned), literal EC/OC comparison, comment/cost/wording items
Campaign OVERHAUL S3 chunk 1 fix round 2 (docs/research/2026-09-01-overhaul/
s3-walk-ownership-map.md §11.6), applied on top of 36be6b598 after the
three-lens re-review. H1-H6, the last round the plan allows.

H1 — the weather OC printed 2-4 times per interior-rooted frame instead of
once: the print (and the `_sky.RenderWeather` mesh draw it sits beside) lived
inside `DrawLandscapeSliceLate`, which `RetailPViewRenderer` calls once per
active `OutsideViewSlices` entry. Moved both into a new
`RetailPViewPassExecutor.DrawWeatherOnce`, called ONCE, unclipped/no-scissor,
after the slice loop in `DrawLandscapeDynamicsPhase` — retail's own
`GameSky::Draw(1)` runs once, after `LScape::draw`'s whole landblock loop.
The gate is extracted as a pure `ShouldDrawWeatherOnce(bool,bool,uint)`
predicate (retail's `SmartBox::is_player_outside` ANDed with the two render
toggles) so `RetailPViewPassExecutorTests` can pin "no OC while the player
stands indoors" without a live GL/DAT `SkyRenderer`; two structural
(CompiledCallGraph) tests prove the call moved out of the per-slice loop and
that the mesh draw + print each fire exactly once per invocation — this
codebase has no existing runtime-construction fixture for
`RetailPViewPassExecutor`, so the pin is structural + a testable pure gate
rather than an end-to-end GL drive. One deliberate deviation from the literal
"gated exactly as today": the print now runs through `_sky?.RenderWeather(...)`
(null-conditional, matching every other `_sky?.RenderSky(...)` call site in
this codebase) instead of an explicit `if (_sky is not null)` wrapper — a
missing sky asset no longer also suppresses the transcript print, since the
print's only job is trace fidelity and retail's own `GameSky` is never null.

H2 — WalkFrameDriver's EC-print comment still claimed "EC and OC counts are
always exactly equal"; replaced with the real citation
(holtburg-doorway-still.walk.log:1126,1131,1134,1137 — four EC prints for one
cell across four look-in DC turns), matching WalkTranscriptDump.
PrintEnvCellShell's own comment (already corrected in round 1).

H3 — the eight-kind signature's ORACLE side
(WalkTraceReplayContext.Signature8(WalkOracleFrame)) derived EC/OC from each
DC's cell list — the SAME derivation the REPLAY side's Recorder already used,
so the comparison could never disagree with itself on EC/OC placement or
content (how G7's SC-ordering regression shipped green with LC/SC). Now reads
its own literally captured EC/OC events, excluding only the trailing
per-frame weather OC — refined beyond the spec's literal "last event + P-cell
id" rule with an additional "P cell itself looks outdoor" check, after
cathedral-arrival and foundry-deep both proved the naive rule also strips an
interior root's OWN real trailing object-list turn when its nearest cell (the
reversed flood loop's last draw) happens to be the camera's own root cell.
All four kit-pose rows still reproduce: three exactly; #458 (this round's new
issue, formerly an inline-only note) re-verified at the SAME token index 165
under the new literal comparison.

H4 — OnSortCellTurn ran RequireOpenFrame + two range validations before
testing the flag. Since the hook is print-only (no stream side effect, unlike
OnLandCellTurn's unconditional WalkFrameEvent record), the flag check now
runs FIRST and returns immediately when off — flag-off cost drops to one
interface dispatch per visited land cell.

H5 — launch-options.md's ACDREAM_DUMP_WALK_TRANSCRIPT row: measured
≈1,200-1,400 lines per outdoor frame (terrace-edge 1,384; cathedral-arrival
1,269; doorway 1,187), replacing the earlier "600-800" estimate; documents
H4's residual flag-off interface-dispatch cost.

H6 — filed docs/ISSUES.md #458 for round 1's LOD-boundary land-cell
divergence (previously only an inline test comment); the InstalledDat lane's
known-failure set is now four (two #383 layout tests, TowerAscent, #458) —
confirmed by a clean run.

MUTATION CHECKS (both restored after confirming failure):
- H1: deleting the OC print inside DrawWeatherOnce made
  DrawWeatherOnce_DrawsTheWeatherMeshAndPrintsExactlyOnce fail with
  "Assert.Single() Failure: The collection did not contain any matching
  items".
- H3: reversing AppendFloodTurns (OC before EC) made
  Still_fixture_first_frame_reproduces_exactly(cathedral-arrival.walk) fail
  ("walk diverged from retail (cathedral-arrival.walk)"), diverging at token
  index 1241: expected "EC:f4180112" vs actual "OC:f4180112".

Gates: hermetic App suite 6823/6823 passed; InstalledDat lane 244/249 passed
with exactly the four known failures (two #383 layout tests, TowerAscent,

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 12:30:30 +02:00

309 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// The FW1 conformance replay harness: reconstructs the camera state from a
/// pose-stamped oracle frame and drives the ported walk over
/// adapter-built world data. Convention notes (adjudicate against the
/// fixtures, loudly, on any mismatch):
/// <list type="bullet">
/// <item>The dumped quaternion is retail Frame storage order w,x,y,z
/// (q0=w) — unit-norm verified on the captures.</item>
/// <item>Retail's frame axes: +Y forward, +Z up (the camera looks along
/// the rotated +Y).</item>
/// <item>Pose origin is landblock-local, the same space the adapter's
/// cell transforms produce.</item>
/// </list>
/// </summary>
public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalkContext
{
// Retail projection globals, dumped live from the capture client
// (recon 2026-08-30 evening: Render::bw/bh/xinvscale/yinvscale/tx/ty/vdst).
public const float RetailViewportWidth = 1024f;
public const float RetailViewportHeight = 720f;
public const float RetailXInvScale = 0.00025f;
public const float RetailYInvScale = 0.00025f;
public const float RetailTx = 0.127875f;
public const float RetailTy = 0.089875f;
public const float RetailVdst = 0.1330766976f;
private sealed class RetailRayCaster(
Vector3 right, Vector3 forward, Vector3 up) : IWalkRayCaster
{
// Retail's unproject (copy_view's ray path; equal to
// ScreenToViewTransform for these globals):
// u = sx·xinvscale tx; w = sy·yinvscale ty
// ray = Xaxis·u + Yaxis·vdst Zaxis·w
public Vector3 RayThrough(float screenX, float screenY)
{
float u = screenX * RetailXInvScale - RetailTx;
float w = screenY * RetailYInvScale - RetailTy;
return right * u + forward * RetailVdst - up * w;
}
}
private readonly Dictionary<uint, WalkCell> _cells;
private readonly Matrix4x4 _viewProjection;
private readonly IWalkRayCaster _rays;
public WalkTraceReplayContext(WalkOraclePose pose, Dictionary<uint, WalkCell> cells)
{
_cells = cells;
WorldViewpoint = pose.Origin;
var rotation = new Quaternion(pose.Q1, pose.Q2, pose.Q3, pose.Q0);
// Basis convention RE-pinned 2026-08-30 (second pass): storage
// w,x,y,z; forward = rotated +Y — the retail Frame convention. The
// motion sweep briefly favored +X, but the walkabout camera was
// mouse-turned off the run direction; the terrace-edge fixture's
// EXTERNAL ground truth (the ledge faces the F518 vista, east)
// decodes east ONLY under +Y-forward, and +Y makes the street
// fixture's punch on/off-screen pattern match retail 4-for-4.
Vector3 forward = Vector3.Transform(Vector3.UnitY, rotation);
Vector3 up = Vector3.Transform(Vector3.UnitZ, rotation);
Vector3 right = Vector3.Transform(Vector3.UnitX, rotation);
// The exact retail frustum: tan(halfFovY) = ty/vdst, aspect = tx/ty.
float fovY = 2f * MathF.Atan(RetailTy / RetailVdst);
Matrix4x4 view = Matrix4x4.CreateLookAt(pose.Origin, pose.Origin + forward, up);
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
fovY, RetailTx / RetailTy, 0.1f, 5000f);
_viewProjection = view * projection;
ViewportWidth = RetailViewportWidth;
ViewportHeight = RetailViewportHeight;
_rays = new RetailRayCaster(right, forward, up);
// The retail CY near plane: N = forward, d = dot(eye, forward) znear.
CyPlane = new WalkPlane(forward, -Vector3.Dot(pose.Origin, forward) - 0.1f);
}
/// <summary>Building placements (camera-block-local) for the landscape
/// fixtures; empty for the interior-only ones.</summary>
public Dictionary<WalkBuilding, WalkWorldDatAdapter.BuildingEntry> Buildings { get; set; }
= new();
private Vector2[] _activeViewVerts = new Vector2[32];
private int _activeViewVertCount;
public Vector3 ViewpointIn(WalkCell cell)
=> Vector3.Transform(WorldViewpoint, cell.InverseWorldTransform);
public Matrix4x4 ObjectToClip(WalkCell cell)
=> cell.WorldTransform * _viewProjection;
public WalkCell? GetVisible(uint cellId) => _cells.GetValueOrDefault(cellId);
public IWalkRayCaster Rays => _rays;
public Vector3 WorldViewpoint { get; }
public float ViewportWidth { get; }
public float ViewportHeight { get; }
public WalkPlane CyPlane { get; }
public IWalkFrameContext CellContext => this;
public void SetActiveView(WalkPortalView views, int index)
{
WalkViewPoly poly = views.View.Polys[index];
if (_activeViewVerts.Length < poly.VertexCount)
_activeViewVerts = new Vector2[poly.VertexCount];
for (int k = 0; k < poly.VertexCount; k++)
_activeViewVerts[k] = views.View.Vertices[poly.VertexIndex + k].Point;
_activeViewVertCount = poly.VertexCount;
}
public Vector3 ViewpointInBuilding(WalkBuilding building)
=> Vector3.Transform(WorldViewpoint, Buildings[building].InverseWorldTransform);
public float ViewerDistanceTo(WalkBuilding building)
=> Vector3.Distance(
WorldViewpoint,
Vector3.Transform(building.SortCenter, Buildings[building].WorldTransform));
public int ClipBuildingPolygon(
WalkBuilding building, WalkPolygon polygon, int side, Span<WalkScreenPoint> output)
{
Matrix4x4 objectToClip = Buildings[building].WorldTransform * _viewProjection;
Span<WalkScreenPoint> projected = stackalloc WalkScreenPoint[polygon.Vertices.Length];
for (int i = 0; i < polygon.Vertices.Length; i++)
projected[i] = WalkScreenClip.TransformToScreen(
polygon.Vertices[i], objectToClip, ViewportWidth, ViewportHeight);
if (side != 0)
projected.Reverse();
return WalkScreenClip.ClipAgainstView(
projected, _activeViewVerts.AsSpan(0, _activeViewVertCount), output);
}
// ---- signatures for comparing walk output to oracle frames ----
public static string Signature(IEnumerable<WalkEvent> events)
=> string.Join("|", events.Select(e => e.Kind switch
{
WalkEventKind.Landscape => "LS",
WalkEventKind.Building => $"BLD:{e.CellId:x8}",
WalkEventKind.DrawInside => $"DI:{e.CellId:x8}",
WalkEventKind.DrawCells =>
$"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}",
_ => "?",
}));
/// <summary>
/// S3 chunk 1 (§11.2 B3): the OH captures interleave <c>LC</c>/<c>SC</c>/
/// <c>EC</c>/<c>OC</c> lines the pre-chunk-3 FW0 fixtures never had.
/// <see cref="RetailFrameWalk"/>'s own <c>WalkEvent</c> vocabulary has
/// exactly four kinds (Landscape/Building/DrawInside/DrawCells) — LC/SC/
/// EC/OC are separate <see cref="IWalkEventSink"/> hooks
/// <see cref="Recorder"/> 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.
/// </summary>
public static string Signature(WalkOracleFrame frame)
=> 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}",
WalkOracleEventKind.DrawInside => $"DI:{e.CellId!.Value:x8}",
WalkOracleEventKind.DrawCells =>
$"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}",
_ => "?",
}));
/// <summary>
/// S3 chunk 1 fix round 1 (G10): the OH kit-pose captures' full
/// eight-kind vocabulary — LS/BLD/DI/DC/LC/SC/EC/OC. ONLY the OH-rooted
/// conformance rows use this (<c>WalkTraceConformanceTests</c>'
/// <c>OhCaptureRoot</c> rows) — the older FW0 fixtures
/// (docs/research/2026-08-30-fw-walk-oracle/) predate the LC/SC/EC/OC
/// cdb breakpoints and carry none of those lines at all, so comparing
/// them at this level would spuriously diverge on every frame (the
/// replay always emits LC/SC; the FW0 oracle frame never has any).
/// <para>
/// S3 chunk 1 fix round 2 (§11.6 H3): the two sides of this comparison
/// are DELIBERATELY ASYMMETRIC. LC/SC/EC/OC all come straight from
/// <paramref name="tokens"/>'s own literal per-hook calls — the REPLAY
/// side's real turns at their real positions, EC/OC included (<see
/// cref="WalkTraceConformanceTests"/>'s <c>Recorder</c> derives them via
/// <see cref="AppendFloodTurns"/> at the exact hook that fires for each
/// flood — see that type's own doc comment). <see
/// cref="Signature8(WalkOracleFrame)"/> below, the ORACLE side, used to
/// derive EC/OC the SAME way from each DC's declared cell list — but
/// that derivation can never disagree with itself, so it silently
/// verified nothing about EC/OC placement or content (round 1's own
/// blind spot: G7's SC-ordering regression shipped green for the same
/// reason with LC/SC). Round 2 fixes this: the oracle side now reads its
/// own CAPTURED <c>EC</c>/<c>OC</c> lines verbatim, in the order retail's
/// cdb breakpoints actually recorded them — a real comparison against a
/// real trace, not a derivation compared to itself. The one exclusion is
/// the trailing per-frame weather <c>OC</c> (§11.6 H1): the LAST event
/// of the frame's raw list, when its cell id matches the frame's own "P"
/// line (<see cref="WalkOracleFrame.Pose"/>) AND that P cell itself looks
/// outdoor (local id &lt; 0x100 — retail's own weather gate,
/// <c>SmartBox::is_player_outside</c>) — an interior root's OWN flood can
/// ALSO end the frame with a real OC matching the P cell (its nearest
/// cell's object-list turn draws last, and that nearest cell is often
/// the camera's own root cell: cathedral-arrival.walk's real trailing
/// "OC f4180108" at DI:f4180108/P-cell f4180108), so position and value
/// alone are not enough — it owns no DC and is not a walk turn at all
/// (see <c>WalkOracleTraceTests</c> for a dedicated
/// assertion that a COMPLETE outdoor frame's raw event list really does
/// end with it).
/// </para>
/// </summary>
public static string Signature8(IReadOnlyList<string> tokens) => string.Join("|", tokens);
/// <summary>The oracle-frame half of <see cref="Signature8(IReadOnlyList{string})"/> —
/// see that overload's doc comment for why this reads EC/OC literally
/// rather than deriving them.</summary>
public static string Signature8(WalkOracleFrame frame)
{
var tokens = new List<string>();
IReadOnlyList<WalkOracleEvent> events = frame.Events;
for (int i = 0; i < events.Count; i++)
{
WalkOracleEvent e = events[i];
switch (e.Kind)
{
case WalkOracleEventKind.Landscape:
tokens.Add("LS");
break;
case WalkOracleEventKind.Building:
tokens.Add($"BLD:{e.CellId!.Value:x8}");
break;
case WalkOracleEventKind.DrawInside:
tokens.Add($"DI:{e.CellId!.Value:x8}");
break;
case WalkOracleEventKind.DrawCells:
tokens.Add(
$"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}");
break;
case WalkOracleEventKind.LandCell:
tokens.Add($"LC:{e.CellId!.Value:x8}");
break;
case WalkOracleEventKind.SortCell:
tokens.Add($"SC:{e.CellId!.Value:x8}");
break;
case WalkOracleEventKind.EnvCellShell:
tokens.Add($"EC:{e.CellId!.Value:x8}");
break;
case WalkOracleEventKind.ObjectCellTurn:
// §11.6 H1's trailing weather OC: the LAST event of the
// whole frame, matching the frame's own "P" cell — not a
// flood turn, excluded from the comparison (see this
// method's own class doc for the citation). The extra
// "P cell is itself an outdoor land cell" check matters:
// an INTERIOR root's OWN flood draws its NEAREST cell's
// object list LAST (PView::DrawCells's reversed loop,
// index 0 = nearest = last) — when that nearest cell IS
// the camera's own root cell, its real OC turn ALSO sits
// at the frame's last position AND shares the P cell's
// id (cathedral-arrival.walk: DI:f4180108, P cell
// f4180108, real trailing "OC f4180108" — position AND
// value alone would misclassify it). Retail's weather
// never draws for an interior-rooted P cell at all
// (SmartBox::is_player_outside is false whenever the
// viewer's own cell id has local part &gt;= 0x100), so
// requiring the P cell itself look outdoor closes that
// hole without reopening G8's original one.
bool trailingWeatherOc = i == events.Count - 1
&& frame.Pose is not null
&& e.CellId!.Value == frame.Pose.CellId
&& (frame.Pose.CellId & 0xFFFFu) < 0x100u;
if (!trailingWeatherOc)
tokens.Add($"OC:{e.CellId!.Value:x8}");
break;
}
}
return string.Join("|", tokens);
}
/// <summary><c>PView::DrawCells</c>'s two complete reverse loops: every
/// EnvCell shell far-to-near, THEN every object-list turn far-to-near —
/// the SAME reversed cell order twice (not one reversed EC/OC pair per
/// cell). S3 chunk 1 fix round 2 (§11.6 H3): this is now ONLY the
/// REPLAY side's derivation — <see cref="WalkTraceConformanceTests"/>'s
/// <c>Recorder</c> calls this at the exact hook that fires for each
/// flood (a look-in's own <c>DrawCells</c> immediately; the interior
/// root's own flood at <c>OnInteriorFloodDrawTurn</c>), matching
/// <c>RetailFrameWalk</c>'s real two-reverse-loop order
/// (<c>WalkFrameDriver.EmitFloodTurns</c>'s own comment has the same
/// citation). <see cref="Signature8(WalkOracleFrame)"/> no longer calls
/// this — the ORACLE side reads its own captured EC/OC lines literally
/// instead of re-deriving them (see that method's own doc comment for
/// why: a derivation compared to itself proves nothing).</summary>
internal static void AppendFloodTurns(List<string> tokens, IReadOnlyList<uint> cells)
{
for (int i = cells.Count - 1; i >= 0; i--)
tokens.Add($"EC:{cells[i]:x8}");
for (int i = cells.Count - 1; i >= 0; i--)
tokens.Add($"OC:{cells[i]:x8}");
}
}