fix(render): S3 chunk 1 round 1 — SC at DrawSortCell entry, the weather OC, real pins with mutation checks

Campaign OVERHAUL S3 chunk 1 fix round 1 (docs/research/2026-09-01-overhaul/
s3-walk-ownership-map.md §11.5), applied on top of 079483bb6 after the
three-lens review. G1-G10, each verified against source and (where the item
adds a pin) mutation-checked.

G1 — the flag-off integration test was vacuous: Collect_TranscriptFlagOff_
ProducesNoConsoleOutput drove ONE frame, and WalkOracleTrace.Parse
structurally discards the last (open) frame, so it passed whether or not
the flag gate worked. Now drives TWO frames and asserts BOTH that Parse
sees zero frames AND that no captured line starts with any of the ten
transcript-kind prefixes (F /P /LS/LC /SC /BLD /DI /DC /EC /OC ) — robust
to unrelated console noise from parallel test classes.

G2 — LC/SC computed LodCellId before testing the flag (~1,300 land-cell
turns/frame of division/modulo/checked-conversion work paid even when the
flag is off). Both OnLandCellTurn and the new OnSortCellTurn now guard the
LodCellId call itself, not just WalkTranscriptDump's internal check.

G3 — the flag-on round trip never exercised LC/SC/BLD (the T2 fixture's
stub 1x1 landscape has zero blocks). New
OutdoorRoot_TranscriptFlagOn_PrintsLandscapeThenLandCellThenSortCellThen
Building_InOrder drives RetailFrameWalk.DrawLandscape directly (the same
deterministic "CY-only" admission technique the existing outdoor LandCell
test uses) over a landscape with one block + one building, and asserts
LS < LC < SC < BLD by position, with the exact cell/building ids.

G4 — the parts/alpha-depth smoke tests asserted FL/PC (Flushes/
DrawCellsSamples) but never AM/PM (MeshAdds/PortalPolyDraws) for the
alpha-depth log, even though PD/DM were already asserted for the parts
log. Added the missing Assert.Contains for MeshAdds and PortalPolyDraws.

G5 — launch-options.md's side-effects column now states the flag off/on
costs explicitly ("one bool read per print site, nothing else" / "one
console line per walk turn, ≈600-800 lines per outdoor frame").

G6 — removed the "DI f4180108... no, a9b4013f" self-correction in
Oh_doorway_still_first_frame_diff's comment; added a one-line note on why
it and Oh_foundry_deep_... stay bespoke Facts rather than folding into the
Theory (no functional reason, just history).

G7 — SC printed from OnLandscapeCellTurn, which RetailFrameWalk.
DrawLandscape calls AFTER DrawBuilding — retail's own order is
LC -> SC -> BLD (terrace-edge.walk.log:2331-2333), not LC -> BLD -> SC.
Fixed by adding IWalkEventSink.OnSortCellTurn, fired at the DrawSortCell
entry point (same alwaysDrawObjects||cellInView gate, before DrawBuilding);
the driver moves its SC print there and OnLandscapeCellTurn no longer
prints one.

G8 — the trailing "OC <viewer land cell>" retail prints once per complete
outdoor frame is GameSky::Draw's weather branch calling
DrawObjCellForDummies(after_sky_cell) @0x005070da, not a walk turn.
RetailPViewPassExecutor.DrawLandscapeSliceLate now prints it at the point
it actually calls RenderWeather; PrintObjectCellTurn's doc, which denied
this line existed, is corrected. New
Oh_terrace_edge_outdoor_frame_ends_with_the_weather_ObjectCellTurn pins
this directly against the OH capture: every complete outdoor frame's last
event is an OC whose cell id equals that same frame's own P-line cell id.

G9 — "EC and OC counts are always exactly equal per pose" was false
(terrace-edge: 12 EC vs 16 OC, the extra 4 being G8's weather OC, which
has no EC counterpart). The visit-scoped/stamp-dedupe conclusion was
right; only the count claim needed fixing. Both doc sites corrected.

G10 — the conformance signature dropped LC/SC/EC/OC entirely, which is
how G7 shipped green: no test ever compared their placement. Recorder now
tracks LC/SC via the new OnLandCellTurn/OnSortCellTurn hooks and derives
EC/OC per DC from PView::DrawCells's own two reverse loops (shell
far-to-near, then object-list far-to-near) — a look-in flood's DC derives
them immediately, the interior root's OWN flood defers to
OnInteriorFloodDrawTurn (verified against cathedral-leak.walk.log:4,1328,
where the root DC's own EC/OC print last). WalkTraceReplayContext.
Signature8 applies the identical derivation to oracle frames and never
produces the trailing weather OC (G8's own dedicated pin covers that
separately, matching the spec's "exclude from the comparison, assert
separately" instruction). Used ONLY for the four OH-capture-rooted
conformance rows (the older FW0 fixtures predate the LC/SC/EC/OC
breakpoints and carry none of those lines).

Applying the eight-kind check surfaced a real, separate bug: WalkLandscape
DatBuilder never set WalkLandBlock.LandblockId, so every LC/SC token any
DAT-built world ever produced read landblockId=0 — latent because the old
four-kind signature filtered LC/SC out. Fixed (one field, test-fixture-
only, no production behavior change). With that fixed, three of the four
OH rows (terrace-edge, cathedral-arrival, foundry-deep) reproduce EXACTLY
at the eight-kind level. Oh_doorway_still_first_frame_diff still diverges
by one extra LC/SC pair (token index 165: replay draws LC:a9c90001|
SC:a9c90001 that retail never does) — a real WalkLandscape.CheckBlocks
visibility question at a ring-2/ring-3 LOD boundary, out of this round's
print-only scope, needing the retail oracle to resolve. Marked
[Trait("Status","KnownFailure")] with the exact position recorded in its
own doc comment, matching TowerAscent's existing pattern — the InstalledDat
lane now shows FOUR known failures (the pre-existing two #383 layout tests
+ TowerAscent, plus this new finding), not three; never weakened the pin
itself.

Mutation checks (all four restored after observing the failure):
- G1: forcing WalkTranscriptDump.Enabled true made
  Collect_TranscriptFlagOff_ProducesNoConsoleOutput fail with
  "Assert.DoesNotContain() Failure: Filter matched in collection ... Collection:
  [\"DI 00000100\", \"DC pv=00000000 ov=1 n=2: 00000100 00000101\", \"LS\", ...]".
- G3: deleting PrintLandCell's Console.WriteLine made the new outdoor test
  fail with "expected an LC line".
- G4: making MeshAddPattern unmatchable made all five
  Alpha_depth_log_parses_with_nonempty_samples rows fail with
  "Assert.Contains() Failure: Filter not matched in collection".
- G7/G10: moving OnSortCellTurn back after DrawBuilding made the
  terrace-edge conformance row fail with "walk diverged from retail
  (terrace-edge.walk)", first divergence exactly at its first building
  (f518002e): expected ...SC:f518002e|BLD:f518002e..., actual
  ...BLD:f518002e|SC:f518002e....

Gates: hermetic lane 6,816/0 (baseline 6,814 + G3's new test + G8's new
test); InstalledDat lane 244/4 known failures (two pre-existing #383
layout tests + TowerAscent + the new Oh_doorway_still_first_frame_diff
finding, documented above).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 11:35:14 +02:00
parent 02a8288172
commit be81475cf9
12 changed files with 502 additions and 43 deletions

View file

@ -237,13 +237,28 @@ public RetailPViewPassExecutor(
bool playerOutside = (frame.PlayerCellId & 0xFFFFu) < 0x100u;
if (frame.RenderSky && frame.RenderWeather && playerOutside)
{
_sky?.RenderWeather(
frame.Camera,
frame.CameraWorldPosition,
frame.DayFraction,
frame.ActiveDayGroup,
frame.SkyKeyframe,
frame.EnvironOverrideActive);
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)
{

View file

@ -149,9 +149,12 @@ public sealed class RetailFrameWalk
/// <summary><c>LScape::draw</c>: visibility per active view, then blocks
/// far-to-near, cells far-to-near; per cell, S3 chunk 3's
/// <see cref="IWalkEventSink.OnLandCellTurn"/> (this cell's terrain,
/// in-view cells only) fires BEFORE the building's turn and the
/// object-list turn (<see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>,
/// every cell under <c>alwaysDrawObjects</c>).</summary>
/// in-view cells only) fires BEFORE S3 chunk 1 fix round 1's
/// <see cref="IWalkEventSink.OnSortCellTurn"/> (DrawSortCell entry,
/// every cell under <c>alwaysDrawObjects</c>), which in turn fires
/// BEFORE the building's turn and the object-list turn
/// (<see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>,
/// DrawSortCell's own far end) — retail's LC → SC → BLD order.</summary>
public void DrawLandscape(
WalkLandscape landscape, WalkPortalView activeViews,
IRetailFrameWalkContext ctx, IWalkEventSink sink)
@ -189,6 +192,17 @@ public sealed class RetailFrameWalk
if (!AlwaysDrawObjects && !cellInView)
continue;
// S3 chunk 1 fix round 1 (G7): RenderDeviceD3D::DrawSortCell
// @0x0059f140 ENTRY — before its own DrawBuilding(building)
// call below (terrace-edge.walk.log:2331-2333 order:
// LC → SC → BLD). The three-lens review's transcript pass
// caught the previous placement (the sink fired this AFTER
// DrawBuilding, via OnLandscapeCellTurn) as a mismatch — see
// that hook's own doc comment for the far-end (post-building)
// turn this one brackets.
sink.OnSortCellTurn(
block.LandblockId, block.SideCellCount, cellIndex);
// RenderDeviceD3D::DrawSortCell @0x0059f140 (decomp-
// confirmed 2026-08-30): DrawBuilding(building) FIRST, then
// DrawObjCell(cell) UNCONDITIONALLY — the building's turn

View file

@ -108,6 +108,21 @@ public interface IWalkEventSink
/// </summary>
void OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex) { }
/// <summary>
/// S3 chunk 1 fix round 1 (G7): fires once per visited landscape cell,
/// at <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 ENTRY — BEFORE
/// that same turn's own <c>DrawBuilding(building)</c> call (retail's
/// terrace-edge.walk.log:2331-2333 order: LC → SC → BLD; a not-in-view
/// cell under <c>alwaysDrawObjects</c> prints SC then BLD with no LC,
/// :1042-1044). Fires under the SAME <c>alwaysDrawObjects || cellInView</c>
/// gate as <see cref="OnLandscapeCellTurn(uint,int,int)"/> (that hook
/// fires at the FAR end of the same DrawSortCell turn, after the
/// building) — the two together bracket retail's real call order,
/// terrain-only DrawLandCell aside. Default no-op — every pre-S3-chunk-1
/// fix-round-1 sink continues to compile and behave identically.
/// </summary>
void OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex) { }
/// <summary>
/// Fires once per visited landscape cell, AFTER that cell's building
/// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140

View file

@ -1383,9 +1383,17 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawLandCell @0x0059f120
// entry — one LC line per admitted LOD land cell, at LOD resolution
// (never expanded to the 8x8 owner-bucket ids OnLandscapeCellTurn
// uses for object-list content).
WalkTranscriptDump.PrintLandCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
// uses for object-list content). S3 chunk 1 fix round 1 (G2): the
// flag gate wraps the LodCellId computation itself, not just
// WalkTranscriptDump.PrintLandCell's own internal check — an
// ordinary launch (flag off) must not pay for the division/modulo/
// checked-conversion work on ~1,300 land-cell turns/frame just to
// discard the result.
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
{
WalkTranscriptDump.PrintLandCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
}
// The one mark rule (this type's own doc comment): flush any
// already-queued stream content ahead of this leaf-renderer event.
@ -1393,9 +1401,45 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Add(WalkFrameEvent.LandCell(landblockId, sideCellCount, cellIndex));
}
/// <summary>S3 chunk 1 fix round 1 (G7): <c>RenderDeviceD3D::
/// DrawSortCell</c> @0x0059f140 ENTRY — fired by <see cref="RetailFrameWalk.DrawLandscape"/>
/// BEFORE that turn's own <c>DrawBuilding</c> call, so the SC line
/// prints between LC and BLD (retail's own order — see the interface
/// member's own doc comment; the three-lens review's transcript pass
/// caught the earlier placement, which printed SC from
/// <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>
void IWalkEventSink.OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex)
{
RequireOpenFrame();
if (sideCellCount is not (1 or 2 or 4 or 8))
{
throw new ArgumentOutOfRangeException(
nameof(sideCellCount),
sideCellCount,
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
}
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));
}
}
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
=> HandleLandscapeCellTurn(cellId);
/// <summary>The object-list half of retail's DrawSortCell turn — see
/// <see cref="IWalkEventSink.OnSortCellTurn"/> for the SC transcript
/// line, which now fires separately, BEFORE the building's own turn
/// (S3 chunk 1 fix round 1, G7); this method never prints one.</summary>
void IWalkEventSink.OnLandscapeCellTurn(
uint landblockId,
int sideCellCount,
@ -1411,13 +1455,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
throw new ArgumentOutOfRangeException(nameof(cellIndex));
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawSortCell @0x0059f140
// entry — ONE SC line per LOD cell (printed before the 8x8-bucket
// expansion below, which is an acdream object-list-content detail
// with no retail-breakpoint analogue of its own).
WalkTranscriptDump.PrintSortCell(
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
uint blockPrefix = landblockId & 0xFFFF0000u;
if (sideCellCount == 8)
{

View file

@ -149,12 +149,15 @@ internal static class WalkTranscriptDump
}
/// <summary><c>RenderDeviceD3D::DrawEnvCell</c> @0x0059f170 entry — fires
/// once per (flood, cell) VISIT, not once per render stamp: the retail
/// captures' EC and OC counts are always exactly equal per pose (e.g.
/// 45/45 at holtburg-doorway-still), which only holds if EC — like OC —
/// is unconditional at every flood visit, with the per-stamp dedupe
/// happening INSIDE DrawEnvCell, after this breakpoint's address, not in
/// the calling loop.</summary>
/// 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;
@ -164,7 +167,17 @@ internal static class WalkTranscriptDump
/// <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 OC hook).</summary>
/// 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
/// (<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;