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([]));
}
}