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>
This commit is contained in:
Erik 2026-09-03 12:14:00 +02:00
parent 9b55d78a28
commit 88c70072e0
8 changed files with 333 additions and 99 deletions

View file

@ -226,39 +226,16 @@ public RetailPViewPassExecutor(
// DrawLandscapeStaticParticles after the slice loop (retail: one
// unclipped alpha-list insertion per emitter), not per slice here.
EnableClipDistances();
// Retail GameSky::Draw @0x00506ff0 gates the WEATHER pass (arg2==1)
// on SmartBox::is_player_outside @0x00451e80 — Ghidra-arbitrated:
// (player objcell_id & 0xFFFF) < 0x100. The sky pass always draws;
// the rain draws ONLY while the PLAYER stands in an outdoor cell.
// That one boolean is retail's entire rain confinement (the
// owner-reported indoor/seam rain at the cathedral: rain on the
// ledges, none the instant the player crosses into an interior
// cell) — no depth or view-clip mechanism is involved.
bool playerOutside = (frame.PlayerCellId & 0xFFFFu) < 0x100u;
if (frame.RenderSky && frame.RenderWeather && playerOutside)
// S3 chunk 1 fix round 2 (§11.6 H1): the sky/rain MESH draw and its
// OC print used to live HERE, re-running once per active landscape
// view (2-4 times for an interior root's exit views) — retail draws
// it ONCE per frame, after LScape::draw's whole landblock loop
// completes (see DrawWeatherOnce's own doc comment). Only the
// per-slice rain PARTICLE emitters stay here — they legitimately
// clip against this slice's own view (ParticleRenderPass.
// SkyPostScene, clipSlot=slice.Slot), unlike the unclipped mesh.
if (ShouldDrawWeatherOnce(frame.RenderSky, frame.RenderWeather, frame.PlayerCellId))
{
if (_sky is not null)
{
_sky.RenderWeather(
frame.Camera,
frame.CameraWorldPosition,
frame.DayFraction,
frame.ActiveDayGroup,
frame.SkyKeyframe,
frame.EnvironOverrideActive);
// S3 chunk 1 fix round 1 (§11.5 G8): GameSky::Draw
// @0x00506ff0's weather branch (arg2==1) calls
// DrawObjCellForDummies(after_sky_cell) @0x005070da when
// LScape::weather_enabled != 0 — the after-sky cell's DID is
// the viewer's own land cell, the SAME id the frame-root "P"
// line already carries (terrace-edge.walk.log:1416
// "OC f418000b" = the P cell, once per complete outdoor
// frame). Print-only, at the point this pass actually calls
// RenderWeather — never gates weather rendering itself.
AcDream.App.Rendering.Walk.WalkTranscriptDump.PrintObjectCellTurn(
frame.ViewerCellId);
}
DisableClipDistances();
if (_particles is not null && _particleRenderer is not null)
{
@ -280,6 +257,73 @@ public RetailPViewPassExecutor(
DisableClipDistances();
}
/// <summary>
/// S3 chunk 1 fix round 2 (§11.6 H1): retail draws the weather pass
/// EXACTLY ONCE per frame — <c>GameSky::Draw</c> @0x00506ff0 with
/// <c>arg2==1</c> runs AFTER <c>LScape::draw</c>'s landblock loop
/// finishes (@0x00506396), not once per active landscape view. The old
/// call site inside <see cref="DrawLandscapeSliceLate"/> ran once per
/// <c>RetailPViewRenderer</c>'s <c>OutsideViewSlices</c> entry — 2-4 for
/// an interior root's exit views — printing 2-4 "OC" lines and
/// re-submitting the rain mesh that many times; every capture shows
/// exactly one. This call is UNCLIPPED and sets no scissor (S3 chunk 4
/// deletes the slice loop's remaining scissor/clip apparatus outright);
/// callers invoke it ONCE, after the slice loop that still runs
/// <see cref="DrawLandscapeSliceLate"/> per view for the per-slice rain
/// particles and dynamics.
/// <para>
/// Gated exactly as before —
/// <see cref="ShouldDrawWeatherOnce(bool,bool,uint)"/>, retail's
/// <c>SmartBox::is_player_outside</c> @0x00451e80 check ANDed with the
/// two render toggles — via <c>_sky?.RenderWeather(...)</c> (null-
/// conditional, matching every other <c>_sky?.RenderSky(...)</c> call
/// site in this codebase): a missing sky asset silently skips the GL
/// draw while the print below still fires, because the print's only
/// job is transcript fidelity to retail's own trace, which never has a
/// null <c>GameSky</c>.
/// </para>
/// </summary>
public void DrawWeatherOnce(RetailPViewFrameInput frame)
{
if (!ShouldDrawWeatherOnce(frame.RenderSky, frame.RenderWeather, frame.PlayerCellId))
return;
DisableClipDistances();
_sky?.RenderWeather(
frame.Camera,
frame.CameraWorldPosition,
frame.DayFraction,
frame.ActiveDayGroup,
frame.SkyKeyframe,
frame.EnvironOverrideActive);
// GameSky::Draw @0x00506ff0's weather branch (arg2==1) calls
// DrawObjCellForDummies(after_sky_cell) @0x005070da when
// LScape::weather_enabled != 0 — the after-sky cell's DID is the
// viewer's own land cell, the SAME id the frame-root "P" line
// already carries (terrace-edge.walk.log:1416 "OC f418000b" = the P
// cell, once per complete outdoor frame — S3 chunk 1 fix round 1
// §11.5 G8's original finding; this round fixes the call site that
// reported it 2-4 times). Print-only — never gates weather
// rendering itself.
AcDream.App.Rendering.Walk.WalkTranscriptDump.PrintObjectCellTurn(frame.ViewerCellId);
}
/// <summary>
/// S3 chunk 1 fix round 2 (§11.6 H1): the weather pass's gate, extracted
/// as a pure predicate — retail's <c>SmartBox::is_player_outside</c>
/// @0x00451e80 check, <c>(player objcell_id &amp; 0xFFFF) &lt; 0x100</c>,
/// ANDed with the two render toggles. Internal (not private) so a test
/// can pin "no weather while the player stands indoors" and "no weather
/// with either toggle off" without a live GL/DAT <see cref="SkyRenderer"/>
/// — <see cref="DrawWeatherOnce"/> itself still needs one to actually
/// draw, but this predicate alone decides whether it would even try (and
/// therefore whether the OC print fires).
/// </summary>
internal static bool ShouldDrawWeatherOnce(
bool renderSky, bool renderWeather, uint playerCellId)
=> renderSky && renderWeather && (playerCellId & 0xFFFFu) < 0x100u;
public void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
uint cellId)

View file

@ -641,10 +641,10 @@ internal sealed class RetailPViewRenderer
// (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn).
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
// GameSky's weather pass still runs through each active landscape
// view so doorway scissor/clip state matches the old executor. The
// dynamics collection is intentionally empty: the walk already drew
// those records at OnLandscapeCellTurn.
// The per-slice rain PARTICLE emitters still run through each
// active landscape view so doorway scissor/clip state matches the
// old executor. The dynamics collection is intentionally empty: the
// walk already drew those records at OnLandscapeCellTurn.
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
@ -656,6 +656,12 @@ internal sealed class RetailPViewRenderer
Array.Empty<WorldEntity>()));
}
// S3 chunk 1 fix round 2 (§11.6 H1): the weather MESH draw + its OC
// print run ONCE here, after the slice loop above — not once per
// slice inside it (DrawWeatherOnce's own doc comment has the
// decomp citation and the retail evidence).
passes.DrawWeatherOnce(ctx);
passes.UseIndoorMembershipOnlyRouting();
}

View file

@ -1410,9 +1410,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
/// <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/> AFTER
/// DrawBuilding had already run). Print-only — no stream-side effect of
/// its own; the object-list turn stays entirely in
/// <c>OnLandscapeCellTurn</c> below, unchanged.</summary>
/// <c>OnLandscapeCellTurn</c> below, unchanged.
/// <para>
/// S3 chunk 1 fix round 2 (§11.6 H4): UNLIKE <see cref="OnLandCellTurn"/>
/// (whose <c>RequireOpenFrame</c>/range validation stay unconditional —
/// that hook always records a real <c>WalkFrameEvent</c>), this hook has
/// NO side effect beyond the print, so the flag gate wraps the ENTIRE
/// body, not just the <c>LodCellId</c> computation: an ordinary launch
/// pays exactly one interface dispatch per visited land cell and nothing
/// else (docs/launch-options.md).
/// </para>
/// </summary>
void IWalkEventSink.OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
return;
RequireOpenFrame();
if (sideCellCount is not (1 or 2 or 4 or 8))
{
@ -1424,13 +1437,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
// S3 chunk 1 fix round 1 (G2): guard BEFORE LodCellId, matching
// OnLandCellTurn's own fix — see that method's comment.
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
{
WalkTranscriptDump.PrintSortCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
}
WalkTranscriptDump.PrintSortCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
}
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
@ -1781,10 +1789,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// 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.
// proof: holtburg-doorway-still.walk.log:1126,1131,1134,1137
// print "EC a9b40100" four times in ONE frame across four
// look-in DC turns that all redraw the same cell. S3 chunk 1 fix
// round 2 (§11.6 H2): "EC and OC counts are always exactly
// equal" (an earlier version of this comment) is FALSE —
// terrace-edge has 12 EC vs 16 OC, the extra 4 being the
// trailing per-frame weather OC (§11.6 H1), which has no EC
// counterpart at all; see WalkTranscriptDump.PrintEnvCellShell's
// own doc comment for the same citation.
WalkTranscriptDump.PrintEnvCellShell(cells[i]);
if (_cellShellsDrawnThisFrame.Add(cells[i]))
{

View file

@ -168,10 +168,14 @@ internal static class WalkTranscriptDump
/// 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): this SAME line kind ALSO
/// prints once more per COMPLETE outdoor frame, from a second call site
/// entirely — <c>RetailPViewPassExecutor.DrawLandscapeSliceLate</c>'s
/// weather pass, matching retail's <c>GameSky::Draw</c> @0x00506ff0
/// 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