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>
208 lines
10 KiB
C#
208 lines
10 KiB
C#
using System.Numerics;
|
||
using System.Text;
|
||
|
||
namespace AcDream.App.Rendering.Walk;
|
||
|
||
/// <summary>
|
||
/// Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the print-only walk-transcript
|
||
/// emitter. Prints the OH oracle-trace line kinds — <c>F</c>/<c>P</c>/
|
||
/// <c>LS</c>/<c>LC</c>/<c>SC</c>/<c>BLD</c>/<c>DI</c>/<c>DC</c>/<c>EC</c>/
|
||
/// <c>OC</c> — to <see cref="Console"/> at the EXACT points retail's cdb
|
||
/// breakpoints sit
|
||
/// (<c>tools/walk-oracle/oh/oh-capture-walk.cdb.template</c>), so a Release
|
||
/// run's output can be diffed offline against a live retail capture (§11.2
|
||
/// B4). Gated by
|
||
/// <see cref="AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled"/>
|
||
/// (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).
|
||
///
|
||
/// <para>Every hex value prints lowercase, 8 digits
|
||
/// (<c>value.ToString("x8")</c>) to match <c>WalkOracleTrace</c>'s
|
||
/// <c>[0-9a-f]{8}</c> regex family — an uppercase or short-padded print
|
||
/// silently fails to parse.</para>
|
||
///
|
||
/// <para>This class never influences admission, depth state, or draw
|
||
/// order — every call site in <see cref="WalkFrameDriver"/> invokes it
|
||
/// AFTER the walk has already decided to emit the corresponding turn.</para>
|
||
/// </summary>
|
||
internal static class WalkTranscriptDump
|
||
{
|
||
private static bool Enabled =>
|
||
AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled;
|
||
|
||
/// <summary>
|
||
/// The frame-root pair — "F n" then "P …" — printed once per
|
||
/// <see cref="WalkFrameDriver.Collect"/> call (retail
|
||
/// <c>SmartBox::RenderNormalMode</c> @0x00453aa0).
|
||
/// <paramref name="forward"/> is the walk's own CY-plane normal
|
||
/// (<c>ctx.CyPlane.Normal</c>) — the SAME forward direction the walk
|
||
/// already derived for this frame (<c>WalkProductionFrameContext</c>/
|
||
/// <c>WalkTraceReplayContext</c> 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
|
||
/// <c>WalkTraceReplayContext.Signature</c>'s DI/DC/BLD/LS/LC/SC/EC/OC
|
||
/// scope). <paramref name="landblockLocalOrigin"/> is precomputed by the
|
||
/// caller from <c>WalkLandscape.ViewerWorldOriginX/Y</c> — 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.
|
||
/// </summary>
|
||
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)}");
|
||
}
|
||
|
||
/// <summary><c>LScape::draw</c> @0x00506330 entry.</summary>
|
||
internal static void PrintLandscape()
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine("LS");
|
||
}
|
||
|
||
/// <summary><c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0 entry.</summary>
|
||
internal static void PrintBuilding(uint positionCellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"BLD {positionCellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary><c>PView::DrawInside</c> @0x005a5860 entry.</summary>
|
||
internal static void PrintDrawInside(uint cellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"DI {cellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>PView::DrawCells</c> @0x005a4840 entry. <paramref name="outdoorPview"/>
|
||
/// 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
|
||
/// <c>WalkFrameDriver.Emit</c>'s <c>DrawCells</c> case). The "pv=" field
|
||
/// prints as an 8-hex-digit 0/1 (<c>00000000</c>/<c>00000001</c>)
|
||
/// rather than a real pointer so it satisfies the SAME
|
||
/// <c>pv=[0-9a-f]{8}</c> regex the retail captures use — the parser
|
||
/// ignores its value either way (§11.2 B1).
|
||
/// </summary>
|
||
internal static void PrintDrawCells(
|
||
bool outdoorPview, int outsideViewCount, IReadOnlyList<uint> 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());
|
||
}
|
||
|
||
/// <summary><c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120 entry.</summary>
|
||
internal static void PrintLandCell(uint cellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"LC {cellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary><c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 entry.</summary>
|
||
internal static void PrintSortCell(uint cellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"SC {cellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary><c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 entry — fires
|
||
/// once per (flood, cell) VISIT, not once per render stamp. S3 chunk 1
|
||
/// fix round 1 (G9): the visit-scoped conclusion is right, but "EC and
|
||
/// OC counts are always exactly equal per pose" is NOT — terrace-edge
|
||
/// has 12 EC vs 16 OC (the extra 4 are G8's trailing per-frame weather
|
||
/// OC, which has no EC counterpart at all). The per-render-stamp dedupe
|
||
/// still sits INSIDE DrawEnvCell, past this breakpoint's address, not in
|
||
/// the calling loop — proof: holtburg-doorway-still.walk.log:1126,1131,
|
||
/// 1134,1137 print <c>EC a9b40100</c> four times in ONE frame across
|
||
/// four look-in DC turns that all redraw the same cell.</summary>
|
||
internal static void PrintEnvCellShell(uint cellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"EC {cellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary><c>RenderDeviceD3D::DrawObjCellForDummies</c> @0x005a0760
|
||
/// entry — the interior/look-in object-list turn (retail's outdoor
|
||
/// <c>DrawObjCell</c> is a different function, folded into the "SC"
|
||
/// line's own DrawSortCell call; it has no separate per-cell OC hook of
|
||
/// its OWN). S3 chunk 1 fix round 1 (G8), corrected round 2 (§11.6 H1):
|
||
/// this SAME line kind ALSO prints EXACTLY once more per COMPLETE
|
||
/// outdoor frame, from a second call site entirely —
|
||
/// <c>RetailPViewPassExecutor.DrawWeatherOnce</c>, called ONCE after the
|
||
/// slice loop (round 1 had this inside
|
||
/// <c>DrawLandscapeSliceLate</c>'s per-slice loop, printing 2-4 times
|
||
/// for an interior root's exit views — a mismatch every capture
|
||
/// disproves) — matching retail's <c>GameSky::Draw</c> @0x00506ff0
|
||
/// (<c>arg2==1</c>) calling <c>DrawObjCellForDummies(after_sky_cell)</c>
|
||
/// @0x005070da when the player stands outside — the after-sky cell's DID
|
||
/// is the viewer's own land cell (the SAME id the frame-root "P" line
|
||
/// already carries). That trailing OC is NOT a walk turn (no owning DC,
|
||
/// no reversal derivation applies to it) — see this method's own two
|
||
/// call sites for the addresses.</summary>
|
||
internal static void PrintObjectCellTurn(uint cellId)
|
||
{
|
||
if (!Enabled) return;
|
||
Console.WriteLine($"OC {cellId.ToString("x8")}");
|
||
}
|
||
|
||
/// <summary>S3 §9.1 R2's LOD cell id: <c>x*8+y+1</c> in LOD coordinates
|
||
/// (a side-2 block's cells are 1, 2, 9, 10), composed onto the
|
||
/// landblock prefix — the SAME id
|
||
/// <c>WalkFrameDriver</c>'s <c>OnLandscapeCellTurn(landblockId,8,cellIndex)</c>
|
||
/// fast path already produces for side==8, generalized to every LOD.
|
||
/// <paramref name="sideCellCount"/>/<paramref name="cellIndex"/> travel
|
||
/// PACKED (LOD resolution), matching <c>OnLandCellTurn</c>'s own
|
||
/// contract — never the expanded 8×8-bucket resolution.</summary>
|
||
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");
|
||
}
|