diff --git a/docs/launch-options.md b/docs/launch-options.md
index 187d00be..cf12e6a3 100644
--- a/docs/launch-options.md
+++ b/docs/launch-options.md
@@ -272,7 +272,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
| `ACDREAM_DUMP_GFXOBJS_DIR` | `=
` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` |
| `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) |
| `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` |
-| `ACDREAM_DUMP_WALK_TRANSCRIPT` | `=1` | Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the production frame walk (`RetailFrameWalk` + `WalkFrameDriver`) prints the OH oracle-trace line kinds — `F`/`P`/`LS`/`LC`/`SC`/`BLD`/`DI`/`DC`/`EC`/`OC` — to `Console.Out` at the exact points retail's cdb breakpoints sit (`tools/walk-oracle/oh/oh-capture-walk.cdb.template`), so a Release run's own transcript can be diffed offline against a live retail capture (frame-by-frame, at the DI/DC/BLD/LS/LC/SC/EC/OC level — the test-side `WalkTranscriptSignatureDiff` helper does the diff, not this flag). | flag off = one bool read per print site, nothing else; flag on = one console line per walk turn (≈600–800 lines per outdoor frame) — a print-only diagnostic for offline diffing, never for ordinary runs. Every print sits AFTER the walk has already decided to emit the corresponding turn — never gates admission, depth state, or draw order. Off, the emitter (`WalkTranscriptDump`) allocates nothing (`WalkFrameDriverTests.TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp`). | off/unset | `RuntimeOptions.DumpWalkTranscript` (typed, sole env read) → `GameWindow.cs` (one-time handoff at construction) → `RenderingDiagnostics.DumpWalkTranscriptEnabled` (the actual gate every call site checks) → `WalkTranscriptDump` / `WalkFrameDriver.cs` |
+| `ACDREAM_DUMP_WALK_TRANSCRIPT` | `=1` | Campaign OVERHAUL S3 chunk 1 (§11.2 B1): the production frame walk (`RetailFrameWalk` + `WalkFrameDriver`) prints the OH oracle-trace line kinds — `F`/`P`/`LS`/`LC`/`SC`/`BLD`/`DI`/`DC`/`EC`/`OC` — to `Console.Out` at the exact points retail's cdb breakpoints sit (`tools/walk-oracle/oh/oh-capture-walk.cdb.template`), so a Release run's own transcript can be diffed offline against a live retail capture (frame-by-frame, at the DI/DC/BLD/LS/LC/SC/EC/OC level — the test-side `WalkTranscriptSignatureDiff` helper does the diff, not this flag). | flag off = one bool read per print site, PLUS one `IWalkEventSink.OnSortCellTurn` interface dispatch per visited land cell (S3 chunk 1 fix round 2 §11.6 H4: that hook is print-only, so its own flag check sits FIRST and skips `RequireOpenFrame`/range validation too — the dispatch itself is the only cost the gate cannot avoid); nothing else. Flag on = one console line per walk turn — measured ≈1,200–1,400 lines per outdoor frame (terrace-edge 1,384; cathedral-arrival 1,269; doorway 1,187) — a print-only diagnostic for offline diffing, never for ordinary runs. Every print sits AFTER the walk has already decided to emit the corresponding turn — never gates admission, depth state, or draw order. Off, the emitter (`WalkTranscriptDump`) allocates nothing (`WalkFrameDriverTests.TranscriptEmitter_FlagOff_EveryPrintMethodIsAZeroCostNoOp`). | off/unset | `RuntimeOptions.DumpWalkTranscript` (typed, sole env read) → `GameWindow.cs` (one-time handoff at construction) → `RenderingDiagnostics.DumpWalkTranscriptEnabled` (the actual gate every call site checks) → `WalkTranscriptDump` / `WalkFrameDriver.cs` |
| `ACDREAM_HIDE_PART` | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` |
| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` |
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index 43429f7f..ed3de2b6 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -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();
}
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): retail draws the weather pass
+ /// EXACTLY ONCE per frame — GameSky::Draw @0x00506ff0 with
+ /// arg2==1 runs AFTER LScape::draw's landblock loop
+ /// finishes (@0x00506396), not once per active landscape view. The old
+ /// call site inside ran once per
+ /// RetailPViewRenderer's OutsideViewSlices 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
+ /// per view for the per-slice rain
+ /// particles and dynamics.
+ ///
+ /// Gated exactly as before —
+ /// , retail's
+ /// SmartBox::is_player_outside @0x00451e80 check ANDed with the
+ /// two render toggles — via _sky?.RenderWeather(...) (null-
+ /// conditional, matching every other _sky?.RenderSky(...) 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 GameSky.
+ ///
+ ///
+ 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);
+ }
+
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): the weather pass's gate, extracted
+ /// as a pure predicate — retail's SmartBox::is_player_outside
+ /// @0x00451e80 check, (player objcell_id & 0xFFFF) < 0x100,
+ /// 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
+ /// — itself still needs one to actually
+ /// draw, but this predicate alone decides whether it would even try (and
+ /// therefore whether the OC print fires).
+ ///
+ internal static bool ShouldDrawWeatherOnce(
+ bool renderSky, bool renderWeather, uint playerCellId)
+ => renderSky && renderWeather && (playerCellId & 0xFFFFu) < 0x100u;
+
public void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
uint cellId)
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index c78c934b..8c28f344 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -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()));
}
+ // 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();
}
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index a0469c5e..a2ed882a 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -1410,9 +1410,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
/// AFTER
/// DrawBuilding had already run). Print-only — no stream-side effect of
/// its own; the object-list turn stays entirely in
- /// OnLandscapeCellTurn below, unchanged.
+ /// OnLandscapeCellTurn below, unchanged.
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H4): UNLIKE
+ /// (whose RequireOpenFrame/range validation stay unconditional —
+ /// that hook always records a real WalkFrameEvent), this hook has
+ /// NO side effect beyond the print, so the flag gate wraps the ENTIRE
+ /// body, not just the LodCellId computation: an ordinary launch
+ /// pays exactly one interface dispatch per visited land cell and nothing
+ /// else (docs/launch-options.md).
+ ///
+ ///
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]))
{
diff --git a/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs b/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs
index caefa075..fc76ac7e 100644
--- a/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkTranscriptDump.cs
@@ -168,10 +168,14 @@ internal static class WalkTranscriptDump
/// entry — the interior/look-in object-list turn (retail's outdoor
/// DrawObjCell 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 — RetailPViewPassExecutor.DrawLandscapeSliceLate's
- /// weather pass, matching retail's GameSky::Draw @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 —
+ /// RetailPViewPassExecutor.DrawWeatherOnce, called ONCE after the
+ /// slice loop (round 1 had this inside
+ /// DrawLandscapeSliceLate's per-slice loop, printing 2-4 times
+ /// for an interior root's exit views — a mismatch every capture
+ /// disproves) — matching retail's GameSky::Draw @0x00506ff0
/// (arg2==1) calling DrawObjCellForDummies(after_sky_cell)
/// @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
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index 6e37b7f4..e36ac743 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -1,6 +1,8 @@
using System.Reflection;
using AcDream.App.Composition;
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Sky;
+using AcDream.App.Rendering.Walk;
using AcDream.App.Tests.Architecture;
namespace AcDream.App.Tests.Rendering;
@@ -109,6 +111,129 @@ public sealed class RetailPViewPassExecutorTests
&& call.Target.Name == ".ctor");
}
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): the weather MESH draw + its OC
+ /// print moved OUT of the per-slice loop into
+ ///
+ /// (that method's own doc comment has the full decomp citation).
+ /// must no
+ /// longer call either or
+ /// 's OC print — only the per-slice
+ /// rain PARTICLE emitters (ParticleRenderer.Draw) stay here.
+ /// MUTATION: re-inlining either call back into this method makes the
+ /// corresponding Assert.DoesNotContain fail.
+ ///
+ [Fact]
+ public void DrawLandscapeSliceLate_NoLongerDrawsOrPrintsTheWeatherPass()
+ {
+ MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
+ nameof(RetailPViewPassExecutor.DrawLandscapeSliceLate),
+ BindingFlags.Instance | BindingFlags.Public)!;
+ IReadOnlyList calls = CompiledCallGraph.Read(method);
+
+ Assert.DoesNotContain(
+ calls,
+ call => call.Target.DeclaringType == typeof(SkyRenderer)
+ && call.Target.Name == nameof(SkyRenderer.RenderWeather));
+ Assert.DoesNotContain(
+ calls,
+ call => call.Target.DeclaringType == typeof(WalkTranscriptDump)
+ && call.Target.Name == "PrintObjectCellTurn");
+ }
+
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): the new call site itself — exactly
+ /// one draw and exactly one OC
+ /// print per invocation (retail draws the weather pass exactly once per
+ /// frame; this method is the ONE call site production now uses — see
+ /// ).
+ /// MUTATION: deleting either call makes the matching
+ /// Assert.Single fail (zero matches instead of one).
+ ///
+ [Fact]
+ public void DrawWeatherOnce_DrawsTheWeatherMeshAndPrintsExactlyOnce()
+ {
+ MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
+ nameof(RetailPViewPassExecutor.DrawWeatherOnce),
+ BindingFlags.Instance | BindingFlags.Public)!;
+ IReadOnlyList calls = CompiledCallGraph.Read(method);
+
+ Assert.Single(
+ calls,
+ call => call.Target.DeclaringType == typeof(SkyRenderer)
+ && call.Target.Name == nameof(SkyRenderer.RenderWeather));
+ Assert.Single(
+ calls,
+ call => call.Target.DeclaringType == typeof(WalkTranscriptDump)
+ && call.Target.Name == "PrintObjectCellTurn");
+ }
+
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): the actual wiring fix — production
+ /// must call DrawWeatherOnce exactly ONCE, positioned AFTER the
+ /// (single, in-loop) DrawLandscapeSliceLate call site. A
+ /// foreach loop compiles to ONE call instruction regardless of
+ /// how many OutsideViewSlices it iterates at runtime — an
+ /// interior root's 2-4 exit views would otherwise still print 2-4 OC
+ /// lines even after
+ /// passes, if DrawWeatherOnce were called FROM INSIDE that same
+ /// loop instead of after it. IL offsets (not list position) prove
+ /// source order for this straight-line method. MUTATION: moving the
+ /// DrawWeatherOnce call back inside the foreach (or before
+ /// the loop) makes the offset-ordering assertion fail; adding a second
+ /// call site makes Assert.Single fail.
+ ///
+ [Fact]
+ public void DrawLandscapeDynamicsPhase_CallsDrawWeatherOnceExactlyOnceAfterTheSliceLoop()
+ {
+ MethodInfo method = typeof(RetailPViewRenderer).GetMethod(
+ "DrawLandscapeDynamicsPhase",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ IReadOnlyList calls = CompiledCallGraph.Read(method);
+
+ CompiledCall sliceLate = Assert.Single(
+ calls,
+ call => call.Target.DeclaringType == typeof(RetailPViewPassExecutor)
+ && call.Target.Name == nameof(RetailPViewPassExecutor.DrawLandscapeSliceLate));
+ CompiledCall weatherOnce = Assert.Single(
+ calls,
+ call => call.Target.DeclaringType == typeof(RetailPViewPassExecutor)
+ && call.Target.Name == nameof(RetailPViewPassExecutor.DrawWeatherOnce));
+
+ Assert.True(
+ sliceLate.Offset < weatherOnce.Offset,
+ "DrawWeatherOnce must be called AFTER the per-slice loop, not from inside it.");
+ }
+
+ ///
+ /// S3 chunk 1 fix round 2 (§11.6 H1): is
+ /// 's
+ /// gate, extracted as a pure predicate so this suite can pin "no OC line
+ /// while the player stands indoors" without a live GL/DAT
+ /// — combined with the two structural tests
+ /// above (moved out of the loop; drawn/printed exactly once per call),
+ /// this proves both halves of the spec's pin: an outdoor root (or an
+ /// interior root with several exit-view slices) prints exactly one OC
+ /// line, and an indoor player prints none. Retail's own check:
+ /// SmartBox::is_player_outside @0x00451e80,
+ /// (player objcell_id & 0xFFFF) < 0x100. MUTATION: negating
+ /// the < 0x100 comparison (or dropping either bool AND) makes
+ /// one of the four rows below fail.
+ ///
+ [Theory]
+ [InlineData(true, true, 0xF4180003u, true)] // outdoor root, player outside a land cell -> draws
+ [InlineData(true, true, 0xA9B40100u, false)] // player indoors (local id >= 0x100) -> no draw
+ [InlineData(false, true, 0xF4180003u, false)] // RenderSky off -> no draw
+ [InlineData(true, false, 0xF4180003u, false)] // RenderWeather off -> no draw
+ public void ShouldDrawWeatherOnce_MatchesRetailIsPlayerOutsideGate(
+ bool renderSky, bool renderWeather, uint playerCellId, bool expected)
+ {
+ Assert.Equal(
+ expected,
+ RetailPViewPassExecutor.ShouldDrawWeatherOnce(renderSky, renderWeather, playerCellId));
+ }
+
private static int RequiredCallIndex(
IReadOnlyList calls,
Type declaringType,
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs
index 0e09ea38..58d7b0a6 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs
@@ -28,8 +28,11 @@ public sealed class WalkTraceConformanceTests
/// flood defers its EC/OC to ),
/// flips true forever once a building's portal pass begins
/// () — see
- /// 's own
- /// doc comment for why this never resets mid-frame.
+ /// 's own doc
+ /// comment for why this never resets mid-frame. S3 chunk 1 fix round 2
+ /// (§11.6 H3): this REPLAY-side derivation is unchanged — only the
+ /// ORACLE side ()
+ /// moved off derivation onto its own literal captured EC/OC lines.
private sealed class Recorder : IWalkEventSink
{
public readonly List Events = new();
@@ -176,9 +179,15 @@ public sealed class WalkTraceConformanceTests
/// boundary near this pose — a real WalkLandscape.CheckBlocks
/// visibility question, not a print-site or ordering bug, and out of
/// this round's print-only scope (§11.4) — needs the retail oracle
- /// (decomp/cdb) to resolve. terrace-edge/cathedral-arrival/foundry-deep
- /// all reproduce EXACTLY at this same eight-kind level; only this row's
- /// specific pose hits the boundary.
+ /// (decomp/cdb) to resolve. Filed as docs/ISSUES.md #458.
+ /// terrace-edge/cathedral-arrival/foundry-deep all reproduce EXACTLY at
+ /// this same eight-kind level; only this row's specific pose hits the
+ /// boundary. S3 chunk 1 fix round 2 (§11.6 H3) switched the ORACLE side
+ /// of the eight-kind signature from a derived EC/OC to retail's own
+ /// LITERAL captured EC/OC lines — re-verified 2026-09-03 that this
+ /// divergence stays at the SAME token index 165 (an LC/SC-only mismatch,
+ /// upstream of any EC/OC content) under the new comparison; #458's
+ /// position is unchanged.
///
[Fact]
[Trait("Status", "KnownFailure")]
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
index 5e4087a8..53a7fe9b 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs
@@ -185,41 +185,50 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk
/// them at this level would spuriously diverge on every frame (the
/// replay always emits LC/SC; the FW0 oracle frame never has any).
///
- /// LC/SC come straight from /'s own literal events — real turns with a real position
- /// on both sides. EC/OC do NOT: 's four-kind
- /// vocabulary has no EC/OC analogue at all, and retail's own real
- /// position for them depends on which flood a DC belongs to
- /// (PView::DrawCells's two complete reverse loops — shell
- /// far-to-near, then object-list far-to-near). A look-in flood's DC
- /// draws them IMMEDIATELY, matching RetailFrameWalk.DrawLandscape's
- /// own recursive DrawCells call; the interior root's OWN flood
- /// DEFERS them past the whole landscape/look-in walk, to
- /// — evidence:
- /// docs/research/2026-09-01-overhaul/oh-capture/cathedral-leak.walk.log
- /// lines 4 and 1328: the root DC's own EC/OC print LAST, after every
- /// intervening look-in's own EC/OC pair. Both signature overloads below
- /// derive EC/OC from each DC's own declared cell list under that SAME
- /// rule (reversed order, twice), rather than reading the oracle's
- /// literal EC/OC lines — this ALSO naturally excludes G8's trailing
- /// per-frame weather OC from the comparison: it owns no DC, so no
- /// derivation rule ever produces a token for it (see
- /// WalkOracleTraceTests for a dedicated assertion that a
- /// COMPLETE outdoor frame's raw event list really does end with it).
+ /// 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
+ /// 's own literal per-hook calls — the REPLAY
+ /// side's real turns at their real positions, EC/OC included ('s Recorder derives them via
+ /// at the exact hook that fires for each
+ /// flood — see that type's own doc comment). 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 EC/OC 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 OC (§11.6 H1): the LAST event
+ /// of the frame's raw list, when its cell id matches the frame's own "P"
+ /// line () AND that P cell itself looks
+ /// outdoor (local id < 0x100 — retail's own weather gate,
+ /// SmartBox::is_player_outside) — 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 WalkOracleTraceTests for a dedicated
+ /// assertion that a COMPLETE outdoor frame's raw event list really does
+ /// end with it).
///
///
public static string Signature8(IReadOnlyList tokens) => string.Join("|", tokens);
/// The oracle-frame half of —
- /// see that overload's doc comment for the shared derivation rule.
+ /// see that overload's doc comment for why this reads EC/OC literally
+ /// rather than deriving them.
public static string Signature8(WalkOracleFrame frame)
{
var tokens = new List();
- bool? lookIn = null;
- IReadOnlyList? pendingRootCells = null;
+ IReadOnlyList events = frame.Events;
- foreach (WalkOracleEvent e in frame.Events)
+ for (int i = 0; i < events.Count; i++)
{
+ WalkOracleEvent e = events[i];
switch (e.Kind)
{
case WalkOracleEventKind.Landscape:
@@ -227,19 +236,13 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk
break;
case WalkOracleEventKind.Building:
tokens.Add($"BLD:{e.CellId!.Value:x8}");
- lookIn = true;
break;
case WalkOracleEventKind.DrawInside:
tokens.Add($"DI:{e.CellId!.Value:x8}");
- lookIn = false;
break;
case WalkOracleEventKind.DrawCells:
tokens.Add(
$"DC:ov={e.OutsideViewCount}:{string.Join(',', e.Cells.Select(c => c.ToString("x8")))}");
- if (lookIn == true)
- AppendFloodTurns(tokens, e.Cells);
- else
- pendingRootCells = e.Cells;
break;
case WalkOracleEventKind.LandCell:
tokens.Add($"LC:{e.CellId!.Value:x8}");
@@ -247,25 +250,55 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk
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;
}
}
- // The interior root's OWN flood: its EC/OC derive last, matching
- // OnInteriorFloodDrawTurn firing as DrawInside's terminal sink call
- // (see class doc) — an outdoor-rooted frame never sets this at all.
- if (pendingRootCells is not null)
- AppendFloodTurns(tokens, pendingRootCells);
-
return string.Join("|", tokens);
}
/// PView::DrawCells'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) — see 's own doc
- /// comment for the evidence. Internal (not private): 's own Recorder reuses this
- /// EXACT rule for the replay side rather than re-deriving it.
+ /// cell). S3 chunk 1 fix round 2 (§11.6 H3): this is now ONLY the
+ /// REPLAY side's derivation — 's
+ /// Recorder calls this at the exact hook that fires for each
+ /// flood (a look-in's own DrawCells immediately; the interior
+ /// root's own flood at OnInteriorFloodDrawTurn), matching
+ /// RetailFrameWalk's real two-reverse-loop order
+ /// (WalkFrameDriver.EmitFloodTurns's own comment has the same
+ /// citation). 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).
internal static void AppendFloodTurns(List tokens, IReadOnlyList cells)
{
for (int i = cells.Count - 1; i >= 0; i--)