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>
248 lines
12 KiB
C#
248 lines
12 KiB
C#
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;
|
|
|
|
public sealed class RetailPViewPassExecutorTests
|
|
{
|
|
[Fact]
|
|
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
|
|
{
|
|
Assert.DoesNotContain(
|
|
typeof(RetailPViewFrameInput).GetProperties(),
|
|
property => typeof(Delegate).IsAssignableFrom(property.PropertyType));
|
|
|
|
FieldInfo[] fields = typeof(RetailPViewPassExecutor).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(CellVisibility));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameInput));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameResult));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(ClipFrameAssembly));
|
|
Assert.DoesNotContain(
|
|
fields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
[Fact]
|
|
public void Concrete_executor_accumulates_walk_terrain_batch_timing()
|
|
{
|
|
// S3 chunk 3 fix round 1 (F3): the walk leaf no longer brackets
|
|
// itself with Begin()/Complete() (that stopwatch-restart pair would
|
|
// push one timing SAMPLE per batch, not one per frame) — it times
|
|
// itself with a raw Stopwatch.GetTimestamp() delta and hands the
|
|
// elapsed ticks to AccumulateWalkBatch, which only accumulates.
|
|
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
|
|
"DrawWalkLandCellBatch",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> landscapeCalls = CompiledCallGraph.Read(landscape);
|
|
int terrainDraw = RequiredCallIndex(
|
|
landscapeCalls,
|
|
typeof(TerrainModernRenderer),
|
|
nameof(TerrainModernRenderer.DrawLandCells));
|
|
int accumulate = RequiredCallIndex(
|
|
landscapeCalls,
|
|
typeof(TerrainDrawDiagnosticsController),
|
|
nameof(TerrainDrawDiagnosticsController.AccumulateWalkBatch));
|
|
|
|
Assert.True(terrainDraw < accumulate);
|
|
Assert.DoesNotContain(
|
|
landscapeCalls,
|
|
call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
|
|
&& call.Target.Name == nameof(TerrainDrawDiagnosticsController.Begin));
|
|
Assert.DoesNotContain(
|
|
landscapeCalls,
|
|
call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
|
|
&& call.Target.Name == nameof(TerrainDrawDiagnosticsController.Complete));
|
|
}
|
|
|
|
[Fact]
|
|
public void Concrete_executor_pushes_the_walk_terrain_frame_sample_at_replay_end()
|
|
{
|
|
// S3 chunk 3 fix round 1 (F3): DrawWalkDrivenStatics is the ONE call
|
|
// site of driver.Replay in production — CompleteWalkTerrainFrame
|
|
// must run immediately after it, so the frame's sample is pushed
|
|
// exactly once, at "the end of the walk replay".
|
|
MethodInfo drawWalkDrivenStatics = typeof(RetailPViewRenderer).GetMethod(
|
|
"DrawWalkDrivenStatics",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(drawWalkDrivenStatics);
|
|
int replay = RequiredCallIndex(
|
|
calls,
|
|
typeof(AcDream.App.Rendering.Walk.WalkFrameDriver),
|
|
nameof(AcDream.App.Rendering.Walk.WalkFrameDriver.Replay));
|
|
int completeWalkFrame = RequiredCallIndex(
|
|
calls,
|
|
typeof(RetailPViewPassExecutor),
|
|
nameof(RetailPViewPassExecutor.CompleteWalkTerrainFrame));
|
|
|
|
Assert.True(replay < completeWalkFrame);
|
|
}
|
|
|
|
[Fact]
|
|
public void Frame_composition_constructs_one_walk_executor()
|
|
{
|
|
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
|
"ComposeCore",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(compose);
|
|
|
|
int executor = RequiredCallIndex(
|
|
calls,
|
|
typeof(RetailPViewPassExecutor),
|
|
".ctor");
|
|
int renderer = RequiredCallIndex(
|
|
calls,
|
|
typeof(WorldScenePViewRenderer),
|
|
".ctor");
|
|
|
|
Assert.True(executor < renderer);
|
|
Assert.Single(
|
|
calls,
|
|
call => call.Target.DeclaringType == typeof(RetailPViewPassExecutor)
|
|
&& call.Target.Name == ".ctor");
|
|
Assert.Single(
|
|
calls,
|
|
call => call.Target.DeclaringType == typeof(WorldScenePViewRenderer)
|
|
&& call.Target.Name == ".ctor");
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3 chunk 1 fix round 2 (§11.6 H1): the weather MESH draw + its OC
|
|
/// print moved OUT of the per-slice loop into
|
|
/// <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/>
|
|
/// (that method's own doc comment has the full decomp citation).
|
|
/// <see cref="RetailPViewPassExecutor.DrawLandscapeSliceLate"/> must no
|
|
/// longer call either <see cref="SkyRenderer.RenderWeather"/> or
|
|
/// <see cref="WalkTranscriptDump"/>'s OC print — only the per-slice
|
|
/// rain PARTICLE emitters (<c>ParticleRenderer.Draw</c>) stay here.
|
|
/// MUTATION: re-inlining either call back into this method makes the
|
|
/// corresponding <c>Assert.DoesNotContain</c> fail.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DrawLandscapeSliceLate_NoLongerDrawsOrPrintsTheWeatherPass()
|
|
{
|
|
MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
|
|
nameof(RetailPViewPassExecutor.DrawLandscapeSliceLate),
|
|
BindingFlags.Instance | BindingFlags.Public)!;
|
|
IReadOnlyList<CompiledCall> 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3 chunk 1 fix round 2 (§11.6 H1): the new call site itself — exactly
|
|
/// one <see cref="SkyRenderer.RenderWeather"/> 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
|
|
/// <see cref="DrawLandscapeDynamicsPhase_CallsDrawWeatherOnceExactlyOnceAfterTheSliceLoop"/>).
|
|
/// MUTATION: deleting either call makes the matching
|
|
/// <c>Assert.Single</c> fail (zero matches instead of one).
|
|
/// </summary>
|
|
[Fact]
|
|
public void DrawWeatherOnce_DrawsTheWeatherMeshAndPrintsExactlyOnce()
|
|
{
|
|
MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
|
|
nameof(RetailPViewPassExecutor.DrawWeatherOnce),
|
|
BindingFlags.Instance | BindingFlags.Public)!;
|
|
IReadOnlyList<CompiledCall> 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3 chunk 1 fix round 2 (§11.6 H1): the actual wiring fix — production
|
|
/// must call <c>DrawWeatherOnce</c> exactly ONCE, positioned AFTER the
|
|
/// (single, in-loop) <c>DrawLandscapeSliceLate</c> call site. A
|
|
/// <c>foreach</c> loop compiles to ONE call instruction regardless of
|
|
/// how many <c>OutsideViewSlices</c> it iterates at runtime — an
|
|
/// interior root's 2-4 exit views would otherwise still print 2-4 OC
|
|
/// lines even after <see cref="DrawLandscapeSliceLate_NoLongerDrawsOrPrintsTheWeatherPass"/>
|
|
/// passes, if <c>DrawWeatherOnce</c> 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
|
|
/// <c>DrawWeatherOnce</c> call back inside the <c>foreach</c> (or before
|
|
/// the loop) makes the offset-ordering assertion fail; adding a second
|
|
/// call site makes <c>Assert.Single</c> fail.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DrawLandscapeDynamicsPhase_CallsDrawWeatherOnceExactlyOnceAfterTheSliceLoop()
|
|
{
|
|
MethodInfo method = typeof(RetailPViewRenderer).GetMethod(
|
|
"DrawLandscapeDynamicsPhase",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3 chunk 1 fix round 2 (§11.6 H1): <see
|
|
/// cref="RetailPViewPassExecutor.ShouldDrawWeatherOnce"/> is
|
|
/// <see cref="DrawWeatherOnce_DrawsTheWeatherMeshAndPrintsExactlyOnce"/>'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
|
|
/// <see cref="SkyRenderer"/> — 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:
|
|
/// <c>SmartBox::is_player_outside</c> @0x00451e80,
|
|
/// <c>(player objcell_id & 0xFFFF) < 0x100</c>. MUTATION: negating
|
|
/// the <c>< 0x100</c> comparison (or dropping either bool AND) makes
|
|
/// one of the four rows below fail.
|
|
/// </summary>
|
|
[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<CompiledCall> calls,
|
|
Type declaringType,
|
|
string methodName)
|
|
{
|
|
int index = CompiledCallGraph.IndexOf(calls, declaringType, methodName);
|
|
Assert.True(
|
|
index >= 0,
|
|
$"Expected call to {declaringType.Name}.{methodName}.");
|
|
return index;
|
|
}
|
|
}
|