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() {