diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index e3ac870d..502779f2 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -394,16 +394,16 @@ public RetailPViewPassExecutor( if (index >= cell.PortalPolygons.Count) break; Vector3[] localVertices = cell.PortalPolygons[index]; - if (localVertices.Length < 3) - continue; - // S4-c1 C1: DrawPortalPolyInternal's degenerate-input guard + // S4-c1 C1 (fix round 1 F1): DrawPortalPolyInternal's + // degenerate-input guard // (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard's // own doc comment) — tested on the LOCAL portal-polygon // vertices, BEFORE the world-transform loop below. A hit drops // the whole polygon: no transform, no fan submission, no // `submitted` increment (retail's reject -> transform -> clip - // -> count order). + // -> count order). No length pre-filter runs before this any + // more (fix round 1 F2 — see the count comment below). if (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(localVertices)) continue; @@ -425,12 +425,25 @@ public RetailPViewPassExecutor( forceFarZ, world[..count]); + // S4-c1 fix round 1 F2: retail increments portalsDrawnCount + // (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) — + // the counter records accepted ATTEMPTS, not successful GPU + // fans (oh1-depth-lifecycle.md's arbitration table). A polygon + // with fewer than 3 vertices is counted here even though + // DrawDepthFan below draws nothing — its own `< 3` guard is + // this port's stand-in for retail's post-clip `var_4 >= 3` + // check. Round 0 counted AFTER that guard (a `< 3` continue + // ahead of both the boundary guard and this increment), which + // silently dropped the count for such a polygon — never + // observed on authored dat data (every real portal polygon has + // >= 3 vertices) but wrong order all the same. + submitted++; + _portalDepthMask.DrawDepthFan( world[..count], frame.ViewProjection, clipPlanes, forceFarZ); - submitted++; } return submitted; } diff --git a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs index 90ee8b6f..abb20f7b 100644 --- a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs +++ b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs @@ -203,33 +203,60 @@ public static class WalkVisibilityMath /// degenerate-input guard (Ghidra-arbitrated: /// docs/research/2026-09-01-overhaul/oh1-depth-lifecycle.md's "Ghidra /// branch arbitration table", row 0x59BCD6–0x59BD28 then - /// 0x59BD40–0x59BD66 — S4-c1 C1). The pseudo-C's own nested-if - /// reading of the four x87 FCOM results (via test ah, 0x44 - /// against a Binary Ninja-synthesized condition byte) is FPU-flag - /// ambiguous and reads backward if taken at face value; the arbitration - /// table's sense governs — per feedback_bn_decomp_field_names.md, - /// a decompiler's flag-mush around x87 compares is a known artifact - /// class, not semantics. + /// 0x59BD40–0x59BD66 — S4-c1 C1, QUANTIFIER corrected at fix + /// round 1 F1: the round-0 "any vertex on any plane" reading was + /// inverted). PDB-paired bytes VA 0x59BCD6–0x59BD66: four + /// per-plane fld/fcomp 12.0/fnstsw/ + /// test ah,0x44/jnp sequences accumulate four booleans — + /// ebx ("every vertex at local x == +12"), var_7 (x == + /// -12), var_5 (y == +12), var_6 (y == -12) — each + /// initialized true and cleared to false by the FIRST vertex that fails + /// its own plane test; the polygon draws only if ebx==0 && + /// var_7==0 && var_6==0 && var_5==0 (pseudo-C + /// 0x59bd42-0x59bd6c), i.e. it is REJECTED only when EVERY local vertex + /// lies on the SAME one of the four bounding planes — a polygon + /// degenerate onto a plane, not a polygon that merely touches one at a + /// single vertex. /// /// Retail tests every SOURCE vertex's LOCAL x and y — BEFORE /// xformStart, the world transform — against exactly +12 - /// and -12. ANY hit on ANY vertex rejects the WHOLE polygon: no - /// transform, no clip, no portalsDrawnCount increment (retail's - /// order is reject → transform → clip → count). Ordinary authored dat - /// portal polygons essentially never land a vertex on that exact - /// boundary — this is a degenerate-input guard, not a clip rule — but - /// it is retail's code, so it is ported as retail's code. + /// 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 + /// punches or seals those 2,163 exit portals, which is very likely the + /// "never-drawn portal polygon = panel" mechanism the PV campaign named + /// (#456). /// public static bool IsRejectedByPortalPolygonBoundaryGuard(ReadOnlySpan localVertices) { + // Four accumulating per-plane predicates (ebx/var_7/var_5/var_6): + // each starts true and is cleared by the first vertex NOT on that + // plane. An empty polygon (retail's num_pts>0 wrap around the whole + // predicate+count block) leaves every predicate vacuously true — + // rejected, matching retail's "nothing happens for num_pts==0". + bool everyVertexOnPlusX = true; + bool everyVertexOnMinusX = true; + bool everyVertexOnPlusY = true; + bool everyVertexOnMinusY = true; + for (int i = 0; i < localVertices.Length; i++) { float x = localVertices[i].X; float y = localVertices[i].Y; - if (x == 12f || x == -12f || y == 12f || y == -12f) - return true; + if (x != 12f) everyVertexOnPlusX = false; + if (x != -12f) everyVertexOnMinusX = false; + if (y != 12f) everyVertexOnPlusY = false; + if (y != -12f) everyVertexOnMinusY = false; } - return false; + + return everyVertexOnPlusX || everyVertexOnMinusX + || everyVertexOnPlusY || everyVertexOnMinusY; } } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 627986eb..282efc3a 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -77,8 +77,10 @@ public sealed class VulkanShaderManifestTests // retail's EXACT bits (uintBitsToFloat(0x3F7FFFEFu), // D3DPolyRender::DrawPortalPolyInternal @0x0059bc90's tail) instead of // the decimal literal 0.99999988f, which round-tripped to a DIFFERENT - // bit pattern (0x3F7FFFFE, fifteen ULPs nearer the camera than retail's - // real constant — see T1 in WalkVisibilityMathTests/the commit body). + // bit pattern (0x3F7FFFFE — FARTHER from the camera, larger z/w, than + // retail's real constant by fifteen ULPs; fix round 1 F4 corrected this + // comment's direction — see T1, + // PortalDepthVert_FarPunchConstant_MatchesRetailExactBits, in THIS file). ["portal_depth.vert.spv"] = "51c60d0924d62c61548efcf5f9e7672a121b1b68ca0a06755e32f1a4d73a8acf", // sky.frag re-pinned 2026-08-23: the dome's fog blend lost its // 0.2 floor and is now applied only under an AdminEnvirons fog diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 17bdedee..6352e6a6 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -1,11 +1,15 @@ +using System.Linq; using System.Numerics; using System.Reflection; using System.Reflection.Emit; using AcDream.App.Composition; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Walk; using AcDream.App.Tests.Architecture; +using AcDream.App.Tests.Rendering.Gpu; namespace AcDream.App.Tests.Rendering; @@ -479,6 +483,126 @@ public sealed class RetailPViewPassExecutorTests || branch.OpCode == OpCodes.Brfalse || branch.OpCode == OpCodes.Brfalse_S)); } + /// + /// S4-c1 fix round 1, F2: retail increments portalsDrawnCount + /// (0x59BD70-0x59BD74) BEFORE polyClipFinish runs (0x59BDB0) — + /// the counter records accepted ATTEMPTS, not successful GPU fans + /// (oh1-depth-lifecycle.md's "Far-Z punches and true-depth exit + /// seals" section). A polygon that survives the boundary guard but has + /// fewer than 3 vertices is still COUNTED by + /// 's returned + /// submitted total, even though + /// draws nothing (its + /// own < 3 guard stands in for retail's post-clip + /// var_4 >= 3 check). Round 0 dropped the count too — a + /// < 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). + /// + [Fact] + public void DrawExitPortalMask_CountsAnUnclippableTwoVertexPolygon_ButDrawsNothing() + { + var cell = new LoadedCell + { + CellId = 0xA9B40105u, + WorldTransform = Matrix4x4.Identity, + }; + cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0)); + // Ordinary (non-degenerate) coordinates — the boundary guard admits + // this polygon — but only TWO vertices: retail's post-clip + // `var_4 >= 3` check (this port's DrawDepthFan `< 3` guard) drops + // the fan submission even though the count already happened. + cell.PortalPolygons.Add( + [ + new Vector3(1f, 1f, 0f), + new Vector3(2f, 1f, 0f), + ]); + + using var device = new RecordingGpuDevice(); + var frames = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope); + var diagnostics = new WorldRenderDiagnostics(new NeverCalledGlStateReader(), new NeverCalledDiagnosticLog()); + + // DrawPortalDepthWrite only reads _portalDepthMask, frame.Cells, + // frame.RootCell.IsOutdoorNode, frame.ViewProjection, and + // _diagnostics (short-circuited off by RenderingDiagnostics. + // ProbeSeamDrawEnabled's default-off value) — the same + // constructor-bypass pattern WalkOutsideViewReassemblyTests already + // uses for exercising a real leaf without a full GL/DAT renderer + // graph. + var executor = (RetailPViewPassExecutor)System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(typeof(RetailPViewPassExecutor)); + typeof(RetailPViewPassExecutor) + .GetField("_portalDepthMask", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(executor, portalDepthMask); + typeof(RetailPViewPassExecutor) + .GetField("_diagnostics", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(executor, diagnostics); + + var root = new LoadedCell { CellId = 0xF4180003u, IsOutdoorNode = false }; + RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset( + rootCell: root, + nearbyBuildingCells: null, + viewerEyePos: Vector3.Zero, + viewProjection: Matrix4x4.Identity, + cells: new SingleCellSource(cell), + camera: null!, + cameraWorldPosition: Vector3.Zero, + frustum: null, + playerLandblockId: null, + animatedEntityIds: null, + renderCenterLbX: 0, + renderCenterLbY: 0, + renderRadius: 0, + landblockEntries: Array.Empty<(uint, Vector3, Vector3, + IReadOnlyList, + IReadOnlyDictionary?)>(), + renderSky: false, + renderWeather: false, + dayFraction: 0f, + activeDayGroup: null, + skyKeyframe: default, + environOverrideActive: false, + viewerCellId: 0, + playerCellId: 0, + playerViewPosition: Vector3.Zero, + cameraView: Matrix4x4.Identity, + cameraCellResolution: default); + + int drawsBefore = device.Calls.OfType().Count(); + int submitted = executor.DrawExitPortalMask(frame, cell.CellId, ReadOnlySpan.Empty); + int drawsAfter = device.Calls.OfType().Count(); + + Assert.Equal(1, submitted); + Assert.Equal(drawsBefore, drawsAfter); + } + + private sealed class SingleCellSource(LoadedCell cell) : IRetailPViewCellSource + { + public LoadedCell? Find(uint cellId) => cellId == cell.CellId ? cell : null; + } + + private sealed class NeverCalledGlStateReader : IRenderGlStateReader + { + public RenderGlStateSnapshot CaptureState() => + throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state."); + + public RenderGlScissorSnapshot CaptureScissor() => + throw new InvalidOperationException("EmitSeamMask must short-circuit before reading GL state."); + } + + private sealed class NeverCalledDiagnosticLog : IRenderFrameDiagnosticLog + { + public void WriteLine(string message) => + throw new InvalidOperationException("EmitSeamMask must short-circuit before logging (ProbeSeamDrawEnabled defaults off)."); + } + private static int RequiredCallIndex( IReadOnlyList calls, Type declaringType, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs new file mode 100644 index 00000000..1ea4611a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkAlphaDepthTrace.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S4-c1 fix round 1, F3: parser for the OH alpha/depth captures +/// (docs/research/2026-09-01-overhaul/oh-capture/*.alphadepth.log — +/// tools/walk-oracle/oh/oh-capture-alpha-depth.cdb.template documents the +/// line formats). Reads the two lines this round's gate cares about: +/// +/// +/// PM poly=<ptr> mode=<0|1> counterBefore=<hex> — +/// one per D3DPolyRender::DrawPortalPolyInternal @0x0059bc90 ENTRY +/// (mode 1 = far-Z building punch, mode 0 = true-depth exit seal). The +/// breakpoint is at function ENTRY, before the boundary guard runs inside +/// the function — a PM line is emitted for every ATTEMPT, guard-rejected +/// or not; counterBefore is the persistent +/// portalsDrawnCount global sampled before THIS call's own possible +/// increment. +/// PC ov=<n> counter=<hex> fc=<0|1> — one per +/// PView::DrawCells @0x005a4840 ENTRY (root turn AND every building +/// look-in's own re-entrant call alike — the breakpoint doesn't +/// distinguish). counter is portalsDrawnCount sampled at +/// THIS call's entry, before its own possible read-then-zero. +/// +/// +/// Frame delimiting mirrors exactly (same +/// F <n> marker, same "events between F_n and F_(n+1) belong to +/// frame n" rule, same final-frame drop) so a caller can parse the SAME +/// capture file with both parsers and get position-consistent frame +/// numbering — for the pose, this type for +/// the PM/PC sequences. +/// +public static class WalkAlphaDepthTrace +{ + public static IReadOnlyList Parse(IEnumerable lines) + { + var frames = new List(); + List<(int Mode, int CounterBefore)>? currentPm = null; + List<(int Ov, int Counter, int ForceClear)>? currentPc = null; + int currentNumber = 0; + + foreach (string line in lines) + { + Match frameMatch = FramePattern.Match(line); + if (frameMatch.Success) + { + if (currentPm is not null) + { + frames.Add(new WalkAlphaDepthFrame(currentNumber, currentPm, currentPc!)); + } + currentNumber = int.Parse( + frameMatch.Groups[1].Value, CultureInfo.InvariantCulture); + currentPm = new List<(int, int)>(); + currentPc = new List<(int, int, int)>(); + continue; + } + if (currentPm is null) + continue; + + Match pm = PmPattern.Match(line); + if (pm.Success) + { + currentPm.Add(( + int.Parse(pm.Groups[1].Value, CultureInfo.InvariantCulture), + int.Parse(pm.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture))); + continue; + } + Match pc = PcPattern.Match(line); + if (pc.Success) + { + currentPc!.Add(( + int.Parse(pc.Groups[1].Value, CultureInfo.InvariantCulture), + int.Parse(pc.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture), + int.Parse(pc.Groups[3].Value, CultureInfo.InvariantCulture))); + continue; + } + // Anything else (FL/AM lines, cdb chrome) is out of this + // parser's scope — ignored, matching WalkOracleTrace's own + // catch-all. + } + + // The last STARTED frame is deliberately never appended — the + // truncated detach frame, same rule as WalkOracleTrace. + return frames; + } + + public static IReadOnlyList Load(string root, string fixtureName) + { + string repoRoot = FindRepositoryRoot(); + string path = Path.Combine( + repoRoot, Path.Combine(root.Split('/')), fixtureName + ".log"); + return Parse(File.ReadLines(path)); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "AcDream.slnx"))) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException( + "AcDream.slnx not found above the test base directory; walk-oracle fixtures unavailable."); + } + + private static readonly Regex FramePattern = new(@"^F (\d+)\s*$", RegexOptions.Compiled); + private static readonly Regex PmPattern = new( + @"^PM poly=[0-9a-f]+ mode=([01]) counterBefore=([0-9a-f]{4})\s*$", RegexOptions.Compiled); + private static readonly Regex PcPattern = new( + @"^PC ov=(\d+) counter=([0-9a-f]{4}) fc=([01])\s*$", RegexOptions.Compiled); +} + +/// One capture frame's PM (mode, counterBefore) and PC (ov, +/// counter, forceClear) sequences, in the exact order the retail trace +/// recorded them. Pointers are intentionally not carried — S4-c1 F3's own +/// comparison ignores them. +public sealed record WalkAlphaDepthFrame( + int Number, + IReadOnlyList<(int Mode, int CounterBefore)> PmEvents, + IReadOnlyList<(int Ov, int Counter, int ForceClear)> PcEvents); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 7d9e4e18..1aae8f00 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -1032,15 +1032,16 @@ public sealed partial class WalkFrameDriverTests Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); } - // ── S4-c1 C1/T2: DrawPortalPolyInternal's degenerate-input guard, ported - // at the punch-fan producer (WalkFrameDriver.OnPunchGeometry — the - // handler that owns the LOCAL polygon before TransformToWorld). A source - // vertex with local x/y exactly +/-12 drops the WHOLE polygon before any - // transform, event, or counter effect; the same shape at 11.999 is an - // ordinary polygon and punches normally. ───────────────────────────── + // ── S4-c1 C1/T2 (quantifier corrected at fix round 1 F1): DrawPortalPoly + // Internal's degenerate-input guard, ported at the punch-fan producer + // (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL + // polygon before TransformToWorld). The polygon is dropped before any + // transform, event, or counter effect ONLY when EVERY local vertex lies + // on the SAME +/-12 plane; a polygon with just one vertex there — or at + // 11.999, just inside — is ordinary and punches normally. ──────────── [Fact] - public void OnPunchGeometry_RejectsWholePolygonOnExactPlusMinus12LocalVertex_ButPunchesJustInside() + public void OnPunchGeometry_RejectsOnlyWhenEveryVertexSharesOnePlane_ButPunchesAnyOtherShape() { using var fx = new DispatcherFixture(); var log = new List(); @@ -1052,12 +1053,23 @@ public sealed partial class WalkFrameDriverTests using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(new TestContext(), Matrix4x4.Identity, Vector3.Zero); - // Degenerate: one vertex sits exactly on the local x == +12 boundary. - // MUTATION: relax the guard's exact equality to a tolerance/ - // inequality (e.g. x >= 12f) and this test's second assertion group - // (the admitted 11.999 polygon) starts failing instead — 11.999 is a - // real, non-degenerate local coordinate a fifth of a millimeter - // (retail units) inside the exact boundary. + // Degenerate: EVERY vertex sits exactly on the local x == +12 + // boundary (degenerate onto the plane) — the only shape retail + // actually rejects. + // MUTATION: restore the any-vertex form (any single vertex == + // +/-12 rejects the whole polygon) — the "one vertex only" case + // below starts failing (wrongly rejected) instead. + sink.OnPunchGeometry( + building, + new WalkPolygon + { + Vertices = [new(12f, -3f, 3f), new(12f, 0f, 3f), new(12f, 5f, 3f)], + Plane = new WalkPlane(Vector3.UnitZ, -3f), + }, + activeViewIndex: 0); + + // Admitted: only ONE vertex sits on x == +12; the other two do not + // share that plane. Retail draws this polygon (punched, counted). sink.OnPunchGeometry( building, new WalkPolygon @@ -1081,12 +1093,14 @@ public sealed partial class WalkFrameDriverTests driver.EndFrame(); driver.Replay(draw.Frame, draw.Pass); - // Exactly ONE punch reached the leaf — the rejected polygon produced - // no PunchFan event at all (not a punch that draws zero vertices; no - // event, full stop). - WalkPolygon punched = Assert.Single(leaf.Punches); - Assert.Equal(new Vector3(11.999f, 0f, 3f), punched.Vertices[1]); - Assert.Equal(1, log.Count(entry => entry == "PUNCH:3@v0")); + // Exactly TWO 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); + 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")); } [Fact] @@ -2004,7 +2018,12 @@ public sealed partial class WalkFrameDriverTests dict[id] = data; } - private readonly struct DrawScope : IDisposable + // S4-c1 fix round 1 F3: DrawScope/DispatcherFixture widened to internal + // so WalkTraceConformanceTests.AlphaDepthTranscriptTests.cs can drive a + // REAL WbDrawDispatcher for the depth-event transcript gate without + // duplicating this entire fixture (RecordingGpuDevice + WbMeshAdapter + + // TextureCache + EntitySpawnAdapter wiring) a second time. + internal readonly struct DrawScope : IDisposable { private readonly IDisposable _publication; private readonly IGpuPassEncoder _pass; @@ -2035,7 +2054,7 @@ public sealed partial class WalkFrameDriverTests } } - private sealed class DispatcherFixture : IDisposable + internal sealed class DispatcherFixture : IDisposable { private readonly WbMeshAdapter _meshAdapter; private readonly TextureCache _textures; diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs new file mode 100644 index 00000000..273cfabc --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs @@ -0,0 +1,340 @@ +using System.Numerics; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Walk; +using DatReaderWriter; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S4-c1 fix round 1, F3: the automated depth-event transcript gate. +/// Replays each OH alpha/depth capture's frame 2 through the SAME ported +/// walk ( + , +/// + +/// — the identical replay harness +/// already uses) with a RECORDING sink/leaf pair that reproduces retail's +/// own PM (mode, counterBefore) and PC (ov, counter, forceClear) sequences, +/// then compares them against 's parse of +/// the real capture, exactly (pointers ignored). +/// +/// Why two observation points, not one. Retail's PM line fires +/// at DrawPortalPolyInternal's function ENTRY — before the four-plane +/// boundary guard (S4-c1 F1) has run — so a PM line is emitted for every +/// ATTEMPT, guard-rejected or not. A building punch's guard decision runs +/// INSIDE (a Collect-time +/// hook): a rejected punch never reaches +/// at all, so intercepts +/// OnPunchGeometry ITSELF, at Collect time, before the guard runs — +/// reading there gives the +/// PRE-reset value retail's own punches observe (Collect completes entirely +/// before Replay starts, so reading the counter at Replay time would already +/// see the root's own post-reset value — the wrong number, confirmed against +/// holtburg-doorway-still's own counterBefore=0002 punches immediately +/// followed by counterBefore=0000/0001 seals in the SAME turn). Exit seals +/// have no such Collect-time per-portal hook at all ( takes no cell/portal +/// argument — RetailPViewPassExecutor.DrawPortalDepthWrite owns the +/// real per-portal loop entirely at Replay time), so +/// reproduces that SAME loop (S4-c1 F1's guard, F2's count-before-clip order) +/// directly over the walk's own +/// / / — +/// the identical LOCAL portal-polygon data the production seal path reads, +/// at Replay time, which is also where retail's own exit-seal loop runs +/// (established: punches always precede the single per-root ExitSeals +/// Collect-time event, so simple list concatenation reproduces the exact +/// 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. +/// +public sealed partial class WalkTraceConformanceTests +{ + /// Collect-time decorator: forwards + /// every hook to the real unchanged (so its + /// own event collection stays complete and correct), while separately + /// recording the two families this gate needs — see this file's own + /// class doc comment for why punches must be captured HERE rather than + /// at Replay. + private sealed class AlphaDepthCollectSink : IWalkEventSink + { + private readonly WalkFrameDriver _driver; + private readonly IWalkEventSink _inner; + + public AlphaDepthCollectSink(WalkFrameDriver driver) + { + _driver = driver; + _inner = driver; + } + + public readonly List<(int Mode, int CounterBefore)> Punches = new(); + public readonly List<(int Ov, int Counter, int ForceClear)> PcEvents = new(); + + public void Emit(in WalkEvent walkEvent) + { + // PView::DrawCells @0x005a4840's own PC print fires at function + // ENTRY for every call — the root's own turn AND every building + // look-in's re-entrant call alike (oh1-depth-lifecycle.md: the + // breakpoint doesn't distinguish). WalkEventKind.DrawCells's + // Emit call is the walk's own "this DrawCells call happened" + // signal at that SAME entry-order point (its own doc comment: + // "sits at breakpoint-ENTRY order"); forceClear (oh1: no write + // site found anywhere in the binary) is always 0. + if (walkEvent.Kind == WalkEventKind.DrawCells) + PcEvents.Add((walkEvent.OutsideViewCount, _driver.PortalsDrawnCount, 0)); + _inner.Emit(walkEvent); + } + + public void OnLandscapeViews(WalkPortalView activeViews) => + _inner.OnLandscapeViews(activeViews); + + public void OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex) => + _inner.OnLandCellTurn(landblockId, sideCellCount, cellIndex); + + public void OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex) => + _inner.OnSortCellTurn(landblockId, sideCellCount, cellIndex); + + public void OnLandscapeCellTurn(uint cellId) => + _inner.OnLandscapeCellTurn(cellId); + + public void OnLandscapeCellTurn(uint landblockId, int sideCellCount, int cellIndex) => + _inner.OnLandscapeCellTurn(landblockId, sideCellCount, cellIndex); + + public void OnBuildingTurn(WalkBuilding building) => + _inner.OnBuildingTurn(building); + + public void OnBuildingShellTurn(WalkBuilding building) => + _inner.OnBuildingShellTurn(building); + + public void OnPunchGeometry( + WalkBuilding building, WalkPolygon polygon, int activeViewIndex) + { + // Far-Z punches never touch portalsDrawnCount (R2); this reads + // it purely as a snapshot for the transcript, BEFORE the SAME + // interior root's own OnInteriorFloodDrawTurn reset — see class + // doc comment. + Punches.Add((1, _driver.PortalsDrawnCount)); + _inner.OnPunchGeometry(building, polygon, activeViewIndex); + } + + public void OnInteriorFloodDrawTurn(IReadOnlyList cells, int outsideViewCount) => + _inner.OnInteriorFloodDrawTurn(cells, outsideViewCount); + + public void OnWeatherTurn(uint viewerCellId) => + _inner.OnWeatherTurn(viewerCellId); + } + + /// Replay-time : every + /// member besides is a plain no-op (this + /// gate cares only about the depth/count mechanism, not mesh content). + /// reproduces + /// RetailPViewPassExecutor.DrawPortalDepthWrite's real per-portal + /// loop — S4-c1 F1's guard, F2's count-before-clip order — directly over + /// the walk's own flood/cell data (no fake GPU clip needed: F2 + /// established the count happens unconditionally once the guard + /// passes). + private sealed class AlphaDepthLeaf : IWalkFrameLeafRenderer + { + private readonly Dictionary _cells; + + public AlphaDepthLeaf(Dictionary cells) => _cells = cells; + + /// Set once, immediately after construction — the leaf and + /// its owning are mutually referential + /// (the driver's constructor requires the leaf), so this cannot be a + /// constructor parameter. + public WalkFrameDriver Driver { get; set; } = null!; + + public readonly List<(int Mode, int CounterBefore)> Seals = new(); + + public void DrawSky() { } + public void DrawLandCellBatch( + IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) { } + public bool HasRenderableEmittersInCell(uint cellId) => false; + public void DrawCellShell(uint cellId) { } + public void DrawStaticParticles(uint cellId) { } + public void DrawCellParticles(uint cellId) { } + public void ClearInteriorDepth() { } + public void FlushLandscape() { } + public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { } + public void AlphaBarrier() { } + + public int DrawExitSeals() + { + int accepted = 0; + List flood = Driver.InteriorFloodCells; + // Reverse order — the SAME direction DrawWalkExitPortalMasks + // walks driver.InteriorFloodCells in production + // (RetailPViewRenderer.cs). + for (int i = flood.Count - 1; i >= 0; i--) + { + if (!_cells.TryGetValue(flood[i], out WalkCell? cell)) + continue; + int sliceCount = Driver.InteriorFloodViewSliceCountAt(i); + for (int slice = 0; slice < sliceCount; slice++) + { + for (int p = 0; p < cell.Portals.Length; p++) + { + WalkCellPortal portal = cell.Portals[p]; + // Walk-side exit sentinel is 32-bit 0xFFFFFFFF + // (WalkCellPortal's own doc comment) — the render- + // side CellPortalInfo's 16-bit 0xFFFF is a DIFFERENT + // representation of the same fact. + if (portal.OtherCellId != 0xFFFFFFFFu) + continue; + if ((uint)portal.PolygonIndex >= (uint)cell.PortalPolygons.Length) + continue; + WalkPolygon poly = cell.PortalPolygons[portal.PolygonIndex]; + + // F2: count happens BEFORE clip (unconditionally, + // once the guard passes) — counterBefore is the + // running total BEFORE this specific portal's own + // decision. + Seals.Add((0, Driver.PortalsDrawnCount + accepted)); + if (!WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard( + poly.Vertices)) + { + accepted++; + } + } + } + } + return accepted; + } + } + + /// Minimal : this gate never + /// inspects mesh content, only the depth/count event sequence, so every + /// query returns the shared empty record. + private sealed class EmptyAlphaDepthWorldData : IWalkFrameWorldData + { + public WalkFrameStaticRecords GetCellStatics(uint cellId) => WalkFrameStaticRecords.Empty; + public WalkFrameStaticRecords GetCellDynamics(uint cellId) => WalkFrameStaticRecords.Empty; + public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => WalkFrameStaticRecords.Empty; + public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) => WalkFrameStaticRecords.Empty; + public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) => + WalkFrameStaticRecords.Empty; + 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 + /// 's captured frame 2 + /// ('s frames[1], matching every + /// other OH-capture row's own frame-2 convention in this file). + private void RunAlphaDepthTranscriptGate(string fixtureName) + { + IReadOnlyList poseFrames = WalkOracleTrace.Load(OhCaptureRoot, fixtureName); + Assert.NotEmpty(poseFrames); + Assert.True(poseFrames.Count > 1, $"{fixtureName}: need a captured frame 2."); + WalkOracleFrame poseFrame = poseFrames[1]; + Assert.NotNull(poseFrame.Pose); + WalkOraclePose pose = poseFrame.Pose!; + + 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]; + + using DatCollection dats = OpenDats(); + WalkLandscapeDatBuilder.BuiltWorld world = + WalkLandscapeDatBuilder.Build(dats, pose.CellId, pose.Origin); + var ctx = new WalkTraceReplayContext(pose, world.Cells) { Buildings = world.Buildings }; + WalkCell? camera = (pose.CellId & 0xFFFFu) >= 0x100 + ? Assert.Contains(pose.CellId, world.Cells) + : null; + + using var fx = new WalkFrameDriverTests.DispatcherFixture(); + var worldData = new EmptyAlphaDepthWorldData(); + var leaf = new AlphaDepthLeaf(world.Cells); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData); + leaf.Driver = driver; + var sink = new AlphaDepthCollectSink(driver); + var walk = new RetailFrameWalk(); + + void RunOnePass() + { + 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(); + } + + // 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(); + + var actualPm = new List<(int Mode, int CounterBefore)>(sink.Punches); + actualPm.AddRange(leaf.Seals); + + Assert.Equal(expected.PmEvents, actualPm); + Assert.Equal(expected.PcEvents, sink.PcEvents); + } + + [Fact] + public void AlphaDepthTranscript_CathedralArrival_MatchesRetailFrame2() + => RunAlphaDepthTranscriptGate("cathedral-arrival.alphadepth"); + + [Fact] + public void AlphaDepthTranscript_FoundryDeep_MatchesRetailFrame2() + => RunAlphaDepthTranscriptGate("foundry-deep.alphadepth"); + + [Fact] + public void AlphaDepthTranscript_HoltburgDoorwayStill_MatchesRetailFrame2() + => 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. + /// + [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. + [Fact] + public void AlphaDepthTranscript_CathedralLeak_MatchesRetailFrame2() + => RunAlphaDepthTranscriptGate("cathedral-leak.alphadepth"); +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 4b8ea466..83a5dbde 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -14,7 +14,7 @@ namespace AcDream.App.Tests.Rendering.Walk; /// lands. /// [Trait("Lane", "InstalledDat")] -public sealed class WalkTraceConformanceTests +public sealed partial class WalkTraceConformanceTests { /// S3 chunk 1 fix round 1 (G10): besides the plain /// list the four-kind diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs index 1d5264d9..7d88e605 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs @@ -217,66 +217,58 @@ public sealed class WalkVisibilityMathTests } // ---- DrawPortalPolyInternal @0x0059bc90's degenerate-input guard - // (S4-c1 C1, T2): ANY source vertex whose LOCAL x or y lands exactly on - // +/-12 rejects the WHOLE polygon. ---- + // (S4-c1 C1, T2 — quantifier corrected at fix round 1 F1): the polygon + // is rejected only when EVERY local vertex lies on the SAME one of the + // four planes x=+12, x=-12, y=+12, y=-12. A polygon with merely ONE + // vertex on a bound is an ordinary polygon and is kept. ---- - [Theory] - [InlineData(12f, 0f)] // x == +12 exactly - [InlineData(-12f, 0f)] // x == -12 exactly - [InlineData(0f, 12f)] // y == +12 exactly - [InlineData(0f, -12f)] // y == -12 exactly - public void Boundary_guard_rejects_a_polygon_with_one_vertex_exactly_on_plus_minus_12( - float x, float y) + [Fact] + public void Boundary_guard_rejects_a_polygon_whose_every_vertex_sits_on_the_same_plusX_plane() { - // MUTATION (verified during S4-c1's own implementation): narrow the - // guard's exact equality to a strict `x > 12f` / `x < -12f` (no - // boundary-inclusive case at all) — the exact +/-12 boundary this - // theory's four rows probe stops rejecting and all four fail. - Vector3[] polygon = [new Vector3(0, 0, 3), new Vector3(x, y, 3), new Vector3(5, 5, 3)]; + // MUTATION: delete the guard (always return false) — this case, + // and every other all-on-plane case below, fails to reject. + Vector3[] polygon = [new Vector3(12f, -3f, 3f), new Vector3(12f, 0f, 3f), new Vector3(12f, 5f, 3f)]; Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); } - [Theory] - [InlineData(11.999f, 0f)] - [InlineData(-11.999f, 0f)] - [InlineData(0f, 11.999f)] - [InlineData(0f, -11.999f)] - public void Boundary_guard_admits_a_polygon_whose_nearest_vertex_is_just_inside_12( - float x, float y) + [Fact] + public void Boundary_guard_rejects_a_polygon_whose_every_vertex_sits_on_the_same_minusY_plane() { - // MUTATION (verified during S4-c1's own implementation): widen the - // guard's exact equality to a near-boundary tolerance, e.g. - // `MathF.Abs(x) >= 11.99f` instead of `x == 12f` — a "close enough - // to the boundary" mistake that still leaves ordinary far-from-12 - // coordinates alone. 11.999 sits inside that widened band, so this - // theory's four rows fail; a strict `x >= 12f` (no tolerance at all) - // does NOT catch this test — 11.999 < 12 either way — which is - // exactly why Boundary_guard_rejects_a_polygon_with_one_vertex_ - // exactly_on_plus_minus_12 above exists as the other half of the - // pin: it fails instead if the guard is narrowed to a strict `>` - // that lets the exact +/-12 boundary through. - Vector3[] polygon = [new Vector3(0, 0, 3), new Vector3(x, y, 3), new Vector3(5, 5, 3)]; + // The y=-12 plane specifically (§7 F1's fourth case) — not just x. + Vector3[] polygon = [new Vector3(-3f, -12f, 3f), new Vector3(0f, -12f, 3f), new Vector3(5f, -12f, 3f)]; + Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); + } + + [Fact] + public void Boundary_guard_admits_a_polygon_with_only_one_vertex_on_plusX12_the_rest_inside() + { + // The round-1 defect: the round-0 "any vertex" reading would have + // rejected this. Retail draws it (punched/sealed, counted) — only + // the DEGENERATE all-on-one-plane polygon is dropped. + // MUTATION: restore the any-vertex form (any vertex == +/-12 + // rejects) — this case starts failing (wrongly rejected) even + // though the guard still correctly rejects the all-on-plane cases. + Vector3[] polygon = [new Vector3(0f, 0f, 3f), new Vector3(12f, 0f, 3f), new Vector3(5f, 5f, 3f)]; Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); } [Fact] - public void Boundary_guard_rejects_the_whole_polygon_even_when_only_one_of_several_vertices_hits_it() + public void Boundary_guard_admits_a_polygon_whose_every_vertex_is_just_inside_11_999() { - // Retail's four var_* flags are OR'd across the WHOLE vertex loop - // before the single post-loop decision (0x59BD42-0x59BD66) — a - // degenerate vertex anywhere in the fan condemns every vertex in it, - // not just its own. MUTATION (verified during S4-c1's own - // implementation): check only localVertices[0] instead of looping - // every vertex — the degenerate vertex here is the LAST of four, so - // the guard would wrongly return false and this fails. - Vector3[] polygon = - [ - new Vector3(-3f, -3f, 3f), - new Vector3(3f, -3f, 3f), - new Vector3(3f, 3f, 3f), - new Vector3(12f, 3f, 3f), // the one degenerate vertex - ]; - Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); + Vector3[] polygon = [new Vector3(11.999f, 0f, 3f), new Vector3(11.999f, 5f, 3f), new Vector3(11.999f, -5f, 3f)]; + Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); + } + + [Fact] + public void Boundary_guard_admits_a_polygon_split_across_plusX12_and_plusY12_no_common_plane() + { + // Two different vertices sit on two DIFFERENT bounding planes; no + // single plane holds every vertex, so nothing rejects it. + // MUTATION: OR the four per-vertex hits instead of the four + // per-plane accumulators (i.e. revert to "any vertex on any plane") + // — this case starts failing (wrongly rejected). + Vector3[] polygon = [new Vector3(12f, 0f, 3f), new Vector3(0f, 12f, 3f), new Vector3(-5f, -5f, 3f)]; + Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon)); } [Fact] @@ -291,8 +283,18 @@ public sealed class WalkVisibilityMathTests } [Fact] - public void Boundary_guard_admits_the_empty_polygon() + public void Boundary_guard_rejects_the_empty_polygon_because_every_plane_predicate_survives_vacuously() { - Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard([])); + // S4-c1 fix round 1 F1 consequence, not an explicit §7 case: retail + // wraps the whole predicate+count block in `if (num_pts > 0)` + // (0x0059bcc9) — for num_pts==0 NOTHING runs: no count, no clip, no + // draw. The four-accumulator port reproduces that "nothing happens" + // effect for free: with no vertex to clear any of them, all four + // per-plane predicates stay at their initial `true`, so the guard + // rejects (bails before transform/clip/count) — the same net effect + // as retail's outer num_pts>0 gate. Real dat portal polygons are + // never empty (the doc comment's DAT scan enumerates only >=3-vertex + // polygons); this is a defensive corner, not a live path. + Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard([])); } }