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>
309 lines
16 KiB
C#
309 lines
16 KiB
C#
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 < 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 >= 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}");
|
||
}
|
||
}
|