diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index 502779f2..5143a780 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -286,10 +286,16 @@ public RetailPViewPassExecutor(
_surface.ClearInteriorDepth();
}
- /// Returns the number of exit-seal fan polygons actually
- /// submitted (S3 §8.2 B2) — the SAME enumeration that draws them, so the
- /// caller's persistent seal counter always matches what reached the GPU
- /// this turn.
+ /// Returns the number of exit-seal fans ATTEMPTED this turn —
+ /// S4-c1 fix round 1 F2 corrected this from "matches what reached the
+ /// GPU": retail increments portalsDrawnCount BEFORE
+ /// polyClipFinish runs (0x59BD70-0x59BD74 precedes 0x59BDB0), so
+ /// the counter records guard-passing ATTEMPTS, not successful GPU fans.
+ /// A portal whose polygon has fewer than 3 vertices is counted here even
+ /// though the GPU renderer's own internal <3 guard
+ /// (DrawDepthFan) draws nothing for it
+ /// (RetailPViewPassExecutorTests.DrawExitPortalMask_CountsAnUnclippableTwoVertexPolygon_ButDrawsNothing
+ /// pins exactly this).
public int DrawExitPortalMask(
RetailPViewFrameInput frame,
uint cellId,
@@ -366,10 +372,16 @@ public RetailPViewPassExecutor(
/// Retail D3DPolyRender::DrawPortalPolyInternal
/// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and
/// look-in apertures stamp far depth (punch). The renderer owns that
- /// choice. Returns the number of portals actually submitted — S3 §8.2 B2:
- /// each portal with OtherCellId == 0xFFFF and >=3 vertices —
- /// the same enumeration WalkFrameDriver.OnInteriorFloodDrawTurn
- /// counts through DrawExitSeals's return value.
+ /// choice. Returns the number of portals ATTEMPTED (not necessarily
+ /// drawn) this turn — S4-c1 fix round 1 F2 retired the ">=3 vertices"
+ /// pre-filter that used to gate the count: every portal with
+ /// OtherCellId == 0xFFFF that survives the ±12 boundary guard
+ /// ()
+ /// is counted here, BEFORE the vertex-count check that decides whether
+ /// its fan actually draws — matching retail's own count-BEFORE-clip
+ /// order (0x59BD70-0x59BD74 precedes 0x59BDB0). This is the same
+ /// enumeration WalkFrameDriver.OnInteriorFloodDrawTurn counts
+ /// through DrawExitSeals's return value.
private int DrawPortalDepthWrite(
uint cellId,
ReadOnlySpan clipPlanes,
diff --git a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
index abb20f7b..9d5c0de2 100644
--- a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
@@ -222,13 +222,32 @@ public static class WalkVisibilityMath
/// xformStart, the world transform — against exactly +12
/// and -12. Retail's order is reject → transform → clip → count
/// (no transform, no clip, no portalsDrawnCount increment on a
- /// reject). A scan of the installed cell DAT (full 0x0000-0xFFFF
- /// landblock-prefix walk, 3,405 landblocks with cells, 1,854,237 portal
- /// polygons; S4-c1 fix round 1 F1) found 2,889 polygons with at least
- /// one vertex on a +/-12 plane and 2,163 polygons with EVERY vertex on
- /// the SAME plane — all 2,163 are EXIT portals (OtherCellId ==
- /// 0xFFFF; 0 interior portals qualify), out of 16,939 exit portals
- /// total. This guard therefore rejects real content: retail never
+ /// reject).
+ ///
+ /// DAT-scan figures (S4-c1 fix round 1 F1, independently
+ /// re-verified at fix round 2 R2-5c with a second, separately-written
+ /// scan over the SAME production EnvCell.CellPortals /
+ /// CellStruct.Polygons shape
+ /// itself walks — both scans agree on every figure below bit-for-bit).
+ /// Id range: the full 0x0000-0xFFFF landblock-prefix
+ /// space, each landblock's cells enumerated 0x0100 through
+ /// 0x0100 + LandBlockInfo.NumCells - 1 (the same range
+ /// CellStructSurfaceConstructionInstalledDatTests' own
+ /// independent OH2 walk uses — its pinned
+ /// landblocksWithCells=3,405 diagnostic already agrees). What is
+ /// counted: one entry per CellPortal whose PolygonId
+ /// resolves in its cell's CellStruct.Polygons AND has >=3
+ /// vertex ids that all resolve in CellStruct.VertexArray — an
+ /// unresolvable PolygonId, a <3-vertex polygon, or a cell
+ /// whose own EnvironmentId/CellStructure does not resolve
+ /// is skipped ENTIRELY (not counted toward the total or either plane
+ /// tally, matching this method's own live callers, which never see such
+ /// a polygon in the first place). Under that rule: 3,405 landblocks with
+ /// cells, 1,854,237 portal polygons; 2,889 with at least one vertex on a
+ /// +/-12 plane, 2,163 with EVERY vertex on the SAME plane — all 2,163
+ /// are EXIT portals (OtherCellId == 0xFFFF; 0 interior portals
+ /// qualify), out of 16,939 exit portals total (1,837,298 interior
+ /// portals). This guard therefore rejects real content: retail never
/// punches or seals those 2,163 exit portals, which is very likely the
/// "never-drawn portal polygon = panel" mechanism the PV campaign named
/// (#456).
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index 6352e6a6..75513f23 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -498,11 +498,18 @@ public sealed class RetailPViewPassExecutorTests
/// < 3 continue ahead of both the boundary guard and the
/// count — never observed on authored dat data (every real portal
/// polygon has >= 3 vertices) but the wrong order all the same.
- /// MUTATION: move the increment back to AFTER
- /// (or restore the
- /// old localVertices.Length < 3 pre-filter) — this pin's
- /// synthetic 2-vertex polygon is no longer counted and the assertion
- /// fails (submitted comes back 0, not 1).
+ /// MUTATION (verified, S4-c1 fix round 2 R2-5b): restore the old
+ /// localVertices.Length < 3 pre-filter ahead of the guard —
+ /// this pin's synthetic 2-vertex polygon is no longer counted and the
+ /// assertion fails (submitted comes back 0, not 1). Moving the
+ /// submitted++ increment itself to AFTER
+ /// — the mutation
+ /// this doc comment used to name alongside the pre-filter one — does
+ /// NOT fail this pin: DrawDepthFan has no effect on the local
+ /// submitted counter either way, so the final returned count is
+ /// identical regardless of which side of that call the increment sits
+ /// on. That reordering is unobservable to any assertion on the return
+ /// value; only the pre-filter mutation is a genuine regression check.
///
[Fact]
public void DrawExitPortalMask_CountsAnUnclippableTwoVertexPolygon_ButDrawsNothing()
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
index 1ea4611a..12a87710 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs
@@ -87,11 +87,40 @@ public static class WalkAlphaDepthTrace
}
public static IReadOnlyList Load(string root, string fixtureName)
+ {
+ return Parse(File.ReadLines(ResolvePath(root, fixtureName)));
+ }
+
+ /// S4-c1 fix round 2 (R2-2): retail's portalsDrawnCount
+ /// (wo(008719b4)) is a PERSISTENT session global carried by the running
+ /// client across every captured frame, so each capture's own FIRST PM or
+ /// PC line — wherever it falls, including the "F 1" preamble text a cdb
+ /// session prints before the first parsed frame marker (terrace-edge and
+ /// holtburg-doorway-still both have their first sample there) — already
+ /// carries the value a real session would have accumulated before the
+ /// capture began. Scans 's raw lines,
+ /// ignoring frame boundaries entirely, for the first line matching
+ /// either or and returns
+ /// its counter field.
+ public static int LoadInitialCounter(string root, string fixtureName)
+ {
+ foreach (string line in File.ReadLines(ResolvePath(root, fixtureName)))
+ {
+ Match pm = PmPattern.Match(line);
+ if (pm.Success)
+ return int.Parse(pm.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ Match pc = PcPattern.Match(line);
+ if (pc.Success)
+ return int.Parse(pc.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ }
+ throw new InvalidOperationException(
+ $"{fixtureName}: no PM/PC line found to seed PortalsDrawnCount from.");
+ }
+
+ private static string ResolvePath(string root, string fixtureName)
{
string repoRoot = FindRepositoryRoot();
- string path = Path.Combine(
- repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log");
- return Parse(File.ReadLines(path));
+ return Path.Combine(repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log");
}
private static string FindRepositoryRoot()
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index 1aae8f00..ea821ee1 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -1090,17 +1090,42 @@ public sealed partial class WalkFrameDriverTests
},
activeViewIndex: 0);
+ // Admitted (S4-c1 fix round 2, R2-3): every vertex sits on SOME
+ // +/-12 plane, but not all on the SAME one — vertex 1's y=0 clears
+ // the y=+12 accumulator, vertex 2's x=0 clears the x=+12
+ // accumulator, so neither of the four per-plane accumulators
+ // survives to the end of the loop. This is the case that tells
+ // retail's real quantifier ("every vertex on the SAME plane") apart
+ // from the weaker "every vertex on SOME plane" (per-vertex OR
+ // across the four planes, ANDed across vertices) — every vertex
+ // here individually touches a plane, yet retail still draws it.
+ // MUTATION: replace the four independent per-plane accumulators
+ // with a single per-vertex "is this vertex on ANY of the four
+ // planes" test ANDed across vertices — this case starts failing
+ // (wrongly rejected): every vertex here IS on some plane, so the
+ // per-vertex-OR form rejects it.
+ sink.OnPunchGeometry(
+ building,
+ new WalkPolygon
+ {
+ Vertices = [new(12f, 0f, 3f), new(0f, 12f, 3f), new(12f, 5f, 3f)],
+ Plane = new WalkPlane(Vector3.UnitZ, -3f),
+ },
+ activeViewIndex: 0);
+
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
- // Exactly TWO punches reached the leaf — the all-on-plane polygon
+ // Exactly THREE punches reached the leaf — the all-on-plane polygon
// produced no PunchFan event at all (not a punch that draws zero
- // vertices; no event, full stop); the other two (one-vertex-on-plane,
- // just-inside) are ordinary and both punched.
- Assert.Equal(2, leaf.Punches.Count);
+ // vertices; no event, full stop); the other three (one-vertex-on-
+ // plane, just-inside, split-across-two-planes) are ordinary and all
+ // punched.
+ Assert.Equal(3, leaf.Punches.Count);
Assert.Equal(new Vector3(12f, 0f, 3f), leaf.Punches[0].Vertices[1]);
Assert.Equal(new Vector3(11.999f, 0f, 3f), leaf.Punches[1].Vertices[1]);
- Assert.Equal(2, log.Count(entry => entry == "PUNCH:3@v0"));
+ Assert.Equal(new Vector3(0f, 12f, 3f), leaf.Punches[2].Vertices[1]);
+ Assert.Equal(3, log.Count(entry => entry == "PUNCH:3@v0"));
}
[Fact]
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs
index 273cfabc..85da59d1 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Walk;
@@ -44,15 +45,34 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// interleaved PM order without needing a shared Collect/Replay timeline).
///
///
-/// Why two Collect+Replay passes per pose, not one. Retail's
-/// portalsDrawnCount is a PERSISTENT global, unaffected by any reset
-/// between captured frames whose interior turn never fires (see
-/// terrace-edge below). holtburg-doorway-still and foundry-deep both show a
-/// STEADY-STATE cycle (the same N seals accepted every captured frame,
-/// carrying the SAME counter value into the next frame's own PC read) —
-/// reachable from a cold PortalsDrawnCount=0 start after exactly one
-/// full "priming" pass, so this gate discards a first Collect+Replay pass's
-/// own output and compares only the second.
+/// Why the counter is SEEDED, not primed by a throwaway pass
+/// (S4-c1 fix round 2, R2-2). Retail's portalsDrawnCount is a
+/// PERSISTENT session global (wo(008719b4)), unaffected by any reset
+/// between captured frames — a running client carries whatever value the
+/// counter last settled at into every subsequent capture. Each fixture's
+/// own FIRST PM or PC line (wherever it falls — including the "F 1"
+/// preamble text a cdb session prints before the first parsed frame
+/// marker, which is where terrace-edge's and holtburg-doorway-still's own
+/// first samples live) already carries that pre-capture value:
+/// cathedral-arrival 0, cathedral-leak 0, foundry-deep 1,
+/// holtburg-doorway-still 2, terrace-edge 2, cathedral-stair-arch 8 (see
+/// 's own doc comment).
+/// Fix round 1 tried to reach a matching steady state by running a
+/// throwaway "priming" Collect+Replay pass first and discarding its
+/// output — this works only for a fixture whose captured frames themselves
+/// contain enough seal activity to climb from a cold
+/// PortalsDrawnCount=0 start up to the needed value (holtburg-
+/// doorway-still, foundry-deep); terrace-edge's own capture never runs a
+/// qualifying interior turn at all (every PC line reads ov=0), so no
+/// number of priming passes from a cold start can ever reach its
+/// counterBefore=2 — the value has to come from BEFORE the capture,
+/// not from replaying the capture against itself. Seeding
+/// directly from the
+/// fixture's own first sample before a SINGLE Collect+Replay pass fixes
+/// this for every pose, terrace-edge included, without changing what a
+/// pose with real seal activity computes (a fixture's own first sample
+/// IS the steady-state value fix round 1's priming pass converged to,
+/// since a steady state is by definition unchanged by one more pass).
///
public sealed partial class WalkTraceConformanceTests
{
@@ -223,11 +243,18 @@ public sealed partial class WalkTraceConformanceTests
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) => Matrix4x4.Identity;
}
- /// Runs the two-pass replay (see class doc comment for why two)
- /// and asserts the SECOND pass's PM/PC sequences against
+ /// Seeds from
+ /// 's own first observed sample (S4-c1 fix
+ /// round 2, R2-2 — see class doc comment), runs ONE Collect+Replay pass,
+ /// and asserts BOTH the PM and PC sequences against
/// 's captured frame 2
/// ('s frames[1], matching every
- /// other OH-capture row's own frame-2 convention in this file).
+ /// other OH-capture row's own frame-2 convention in this file).
+ /// R2-1 (gate honesty): both diffs are computed BEFORE either is
+ /// asserted, so a PM mismatch can never prevent the PC comparison from
+ /// running (or vice versa) — a written claim that one sequence
+ /// "matches" is only ever one this method actually evaluated.
+ ///
private void RunAlphaDepthTranscriptGate(string fixtureName)
{
IReadOnlyList poseFrames = WalkOracleTrace.Load(OhCaptureRoot, fixtureName);
@@ -240,6 +267,7 @@ public sealed partial class WalkTraceConformanceTests
IReadOnlyList depthFrames = WalkAlphaDepthTrace.Load(OhCaptureRoot, fixtureName);
Assert.True(depthFrames.Count > 1, $"{fixtureName}: need a captured frame 2's PM/PC content.");
WalkAlphaDepthFrame expected = depthFrames[1];
+ int initialCounter = WalkAlphaDepthTrace.LoadInitialCounter(OhCaptureRoot, fixtureName);
using DatCollection dats = OpenDats();
WalkLandscapeDatBuilder.BuiltWorld world =
@@ -257,40 +285,45 @@ public sealed partial class WalkTraceConformanceTests
var sink = new AlphaDepthCollectSink(driver);
var walk = new RetailFrameWalk();
- void RunOnePass()
+ // R2-2: seed the persistent counter from the fixture's own
+ // pre-capture value instead of priming a throwaway first pass.
+ driver.PortalsDrawnCount = initialCounter;
+
+ using (WalkFrameDriverTests.DrawScope draw = fx.BeginDraw())
{
- using (WalkFrameDriverTests.DrawScope draw = fx.BeginDraw())
- {
- driver.BeginFrame(ctx, Matrix4x4.Identity, pose.Origin);
- walk.WalkFrame(pose.CellId, camera, world.Landscape, ctx, sink);
- driver.EndFrame();
- driver.Replay(draw.Frame, draw.Pass);
- }
- // DrawScope.Dispose ends only the pass/publication — the frame
- // itself (RecordingGpuDevice's "one open frame" invariant) is
- // ended separately, same order WalkOutsideViewReassemblyTests'
- // own multi-draw loop uses.
- fx.FrameLifetime.EndFrame();
+ driver.BeginFrame(ctx, Matrix4x4.Identity, pose.Origin);
+ walk.WalkFrame(pose.CellId, camera, world.Landscape, ctx, sink);
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
}
-
- // Priming pass: reaches the steady state a real running session
- // would already be in by the time retail's capture began (see class
- // doc comment) — its own recorded output is discarded.
- RunOnePass();
- sink.Punches.Clear();
- sink.PcEvents.Clear();
- leaf.Seals.Clear();
-
- // Comparison pass.
- RunOnePass();
+ // DrawScope.Dispose ends only the pass/publication — the frame
+ // itself (RecordingGpuDevice's "one open frame" invariant) is
+ // ended separately, same order WalkOutsideViewReassemblyTests'
+ // own multi-draw loop uses.
+ fx.FrameLifetime.EndFrame();
var actualPm = new List<(int Mode, int CounterBefore)>(sink.Punches);
actualPm.AddRange(leaf.Seals);
+ IReadOnlyList<(int Ov, int Counter, int ForceClear)> actualPc = sink.PcEvents;
- Assert.Equal(expected.PmEvents, actualPm);
- Assert.Equal(expected.PcEvents, sink.PcEvents);
+ bool pmMatches = expected.PmEvents.SequenceEqual(actualPm);
+ bool pcMatches = expected.PcEvents.SequenceEqual(actualPc);
+ if (!pmMatches || !pcMatches)
+ {
+ Assert.Fail(
+ $"{fixtureName}: PM {(pmMatches ? "matches" : "DIVERGES")} " +
+ $"— expected {FormatPm(expected.PmEvents)}, actual {FormatPm(actualPm)}; " +
+ $"PC {(pcMatches ? "matches" : "DIVERGES")} " +
+ $"— expected {FormatPc(expected.PcEvents)}, actual {FormatPc(actualPc)}");
+ }
}
+ private static string FormatPm(IReadOnlyList<(int Mode, int CounterBefore)> events) =>
+ "[" + string.Join(", ", events.Select(e => $"(mode={e.Mode},counterBefore={e.CounterBefore})")) + "]";
+
+ private static string FormatPc(IReadOnlyList<(int Ov, int Counter, int ForceClear)> events) =>
+ "[" + string.Join(", ", events.Select(e => $"(ov={e.Ov},counter={e.Counter},fc={e.ForceClear})")) + "]";
+
[Fact]
public void AlphaDepthTranscript_CathedralArrival_MatchesRetailFrame2()
=> RunAlphaDepthTranscriptGate("cathedral-arrival.alphadepth");
@@ -304,36 +337,42 @@ public sealed partial class WalkTraceConformanceTests
=> RunAlphaDepthTranscriptGate("holtburg-doorway-still.alphadepth");
///
- /// PINNED KnownFailure (S4-c1 fix round 1, F3): terrace-edge never runs
- /// a qualifying interior turn anywhere in its own capture (every PC line
- /// reads ov=0) — its two building punches' counterBefore=2
- /// is a PERSISTENT session value carried over from BEFORE the capture
- /// even started (the file's own pre-"F 1" content already reads 2; no
- /// mechanism inside the captured frames ever changes it). A fresh two-
- /// pass replay from PortalsDrawnCount=0 has no way to derive that
- /// leftover value — there is no seal activity anywhere in this fixture
- /// to prime it — so this pose's own comparison pass reads
- /// counterBefore=0 where retail shows 2. This is an initial-
- /// condition gap in the fixture itself, not a guard/count defect: the
- /// SAME replay harness reproduces holtburg-doorway-still's and foundry-
- /// deep's own steady-state counter values exactly from a cold start.
- /// Observed divergence (both PM events, the fixture's only two):
- /// expected [(mode=1, counterBefore=2), (mode=1, counterBefore=2)],
- /// actual [(mode=1, counterBefore=0), (mode=1, counterBefore=0)];
- /// the PC sequence (ov=0 both times) matches exactly.
+ /// S4-c1 fix round 2 (R2-2): seeding
+ /// from terrace-edge's own first observed sample (counterBefore=2,
+ /// carried in the file's pre-"F 1" preamble text — see
+ /// 's own doc
+ /// comment) closes fix round 1's KnownFailure: both of this fixture's
+ /// PM events now read the correct pre-capture value instead of a cold
+ /// 0, and its PC sequence (ov=0 throughout — this pose
+ /// never runs a qualifying interior turn at all) already matched.
///
[Fact]
- [Trait("Status", "KnownFailure")]
public void AlphaDepthTranscript_TerraceEdge_MatchesRetailFrame2()
=> RunAlphaDepthTranscriptGate("terrace-edge.alphadepth");
- /// See 's
- /// own doc comment — cathedral-leak carries the exact same steady state
- /// (counter pinned at 0, every exit portal degenerate-onto-plane
- /// rejected) as cathedral-arrival, so it is expected to pass; listed
- /// here because the spec's own fixture count (four) undercounts the
- /// five *.alphadepth.log files actually present in
- /// oh-capture/ — see this round's commit body.
+ ///
+ /// S4-c1 fix round 2, R2-4: the sixth pose (#464 artifact pose, root
+ /// 0xF4180114, seven outside views). Sixteen building punches —
+ /// all mode=1, all counterBefore=0008 (far-Z punches never
+ /// touch the counter) — of which twelve are guard-rejected on local
+ /// y=12.000 (WalkVisibilityMath's ±12 boundary guard, S4-c1 F1),
+ /// leaving four admitted; then eight true-depth exit seals with
+ /// counterBefore running 0..7 against the root's own
+ /// ov=7 read-then-zero, settling this fixture's own steady-state
+ /// counter at 8 — the same value
+ /// reads back out of this fixture's own preamble, confirming the seed
+ /// and the replay agree on the same persistent value.
+ ///
+ [Fact]
+ public void AlphaDepthTranscript_CathedralStairArch_MatchesRetailFrame2()
+ => RunAlphaDepthTranscriptGate("cathedral-stair-arch.alphadepth");
+
+ /// Cathedral-leak carries the exact same steady state (counter
+ /// pinned at 0, every exit portal degenerate-onto-plane rejected) as
+ /// cathedral-arrival; listed here (S4-c1 fix round 1, F3) because the
+ /// original spec's own fixture count (four) undercounts the five
+ /// *.alphadepth.log files that were already present in
+ /// oh-capture/ at that round — see that round's commit body.
[Fact]
public void AlphaDepthTranscript_CathedralLeak_MatchesRetailFrame2()
=> RunAlphaDepthTranscriptGate("cathedral-leak.alphadepth");
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
index 7d88e605..bbbb116d 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
@@ -271,6 +271,30 @@ public sealed class WalkVisibilityMathTests
Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
}
+ [Fact]
+ public void Boundary_guard_admits_a_polygon_whose_every_vertex_is_on_SOME_plane_but_not_the_SAME_one()
+ {
+ // S4-c1 fix round 2, R2-3: the existing split-plane case above
+ // (12,0,3)/(0,12,3)/(-5,-5,3) does NOT discriminate retail's real
+ // quantifier ("every vertex on the SAME plane") from the weaker
+ // "every vertex on SOME plane" (per-vertex OR across the four
+ // planes, ANDed across vertices) — its third vertex sits on NO
+ // plane at all, so both readings admit it for the same reason. This
+ // case closes that gap: EVERY vertex here touches a plane —
+ // (12,0,3) and (12,5,3) sit on x=+12, (0,12,3) sits on y=+12 — yet
+ // no SINGLE plane holds all three: x=+12 is cleared by vertex 2
+ // (x=0), y=+12 is cleared by vertex 1 (y=0). Retail's four
+ // independent per-plane accumulators admit it (neither survives to
+ // the end of the loop).
+ // MUTATION: replace the four per-plane accumulators with a single
+ // per-vertex "is this vertex on ANY of the four planes" test ANDed
+ // across vertices — this case starts failing (wrongly rejected):
+ // every vertex here IS on some plane, so the per-vertex-OR form
+ // rejects it.
+ Vector3[] polygon = [new Vector3(12f, 0f, 3f), new Vector3(0f, 12f, 3f), new Vector3(12f, 5f, 3f)];
+ Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
+ }
+
[Fact]
public void Boundary_guard_ignores_the_vertical_z_component()
{