diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index fee8cf6c..5656be52 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -4477,36 +4477,38 @@ staircase entity's per-frame draw decision.
## #129 — Doors/doorways leak through terrain and houses from over a landblock away
-**Status:** OPEN
+**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM (visible at distance during normal outdoor play)
**Filed:** 2026-06-12 (user report, post-#119-close session)
-**Component:** render — aperture depth punch at distance (#117 family)
+**Component:** render — aperture depth punch at distance (#117 family, AD-18)
**Symptom (user):** "leakage of like doors and doorways through the
terrain and houses over a landblock" — door/doorway-shaped patches
visible THROUGH intervening terrain and nearer buildings when the
source building is roughly a landblock (~192 m) or more away.
-**Leads:**
-1. **The #117 stencil depth-gate bias at long range (top suspect).**
- #117's fix (`478c549`) marks aperture pixels at biased true depth
- (LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With
- a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of
- view depth — the bias can exceed the separation between the aperture
- and a hill/house in front of it, marking occluder pixels and punching
- them → the occluder shows the interior/background behind. The #108
- coverage constraint pulls the bias up; distance pulls it wrong —
- re-derive the bias in eye-space (or scale by w) instead of constant
- NDC.
-2. Per-building look-in floods admitting distant buildings (the #127
- churn family) — would gate WHICH buildings punch, not the
- through-occluder leak itself.
+**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
+the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
+non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
+depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
+but **~190 m at a landblock**: every hill/house in front of a distant
+aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
+leak. Exactly AD-18's recorded "Risk if assumption breaks".
-**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot +
-player/eye position from [snap]/[viewer]); confirm whether the leak
-patch matches an aperture polygon of the distant building; then test
-the eye-space-bias hypothesis headlessly (the #117 commit has the bias
-math).
+**Fix (2026-06-12):** cap the bias's EYE-SPACE span —
+`biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
+(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
+Below the ~10 m crossover the constant term wins, bit-identical to the
+T5-validated behavior (#108 grass coverage untouched); beyond it the
+punch can never reach an occluder more than 0.5 m in front of the
+aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m
+at a landblock; capped form ≤0.5 m at all distances; close range
+unchanged).
+
+**Gate:** the original spot — distant building doors no longer show
+through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep
+stays gone up close. If a >10 m-range #108-class residue appears, the
+cap constant (0.5 m) is the tuning knob — see AD-18.
---
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index f7612b9a..64cdf3fe 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -79,7 +79,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
-| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
+| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
index 969396b8..4f0a6436 100644
--- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
+++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
@@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
-uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
+uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
+uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
out float gl_ClipDistance[8];
void main()
{
@@ -62,7 +63,14 @@ void main()
if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
else if (uDepthBias > 0.0)
- clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
+ {
+ // #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
+ // an NDC bias b spans ~b*d*d/near meters of eye depth, so the
+ // constant-NDC form alone reached METERS at distance (door-shaped
+ // leaks through hills/houses). Keep in sync with MarkBiasNdc.
+ float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
+ clipPos.z -= biasNdc * clipPos.w;
+ }
gl_Position = clipPos;
}";
@@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
+ private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
+ _locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
- /// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
///
private const float PunchMarkDepthBias = 0.0005f;
+ ///
+ /// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
+ /// spans ≈ b·d²/near meters of eye depth at eye distance d. With
+ /// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
+ /// ~190 m at a landblock away: every hill/house in front of a distant
+ /// aperture passed the mark and got far-Z punched — door-shaped leaks
+ /// through occluders. Fix: cap the bias's EYE-SPACE span at
+ /// . Below the ~10 m crossover
+ /// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
+ /// bit-identical to the T5-validated close-range behavior (#108 grass
+ /// coverage untouched); beyond it the punch can never reach an occluder
+ /// more than the cap in front of the aperture plane.
+ ///
+ public const float PunchMarkBiasEyeCapMeters = 0.5f;
+
+ /// Retail Render::znear = 0.1 (decomp :342173, re-landed
+ /// d4b5c71). The cap conversion below assumes the production camera near
+ /// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored.
+ public const float CameraNearPlaneMeters = 0.1f;
+
+ /// CPU mirror of the vertex-shader mark-bias expression (keep in
+ /// sync with VertSrc): the NDC bias applied at eye depth
+ /// .
+ public static float MarkBiasNdc(float eyeDepthMeters) =>
+ MathF.Min(PunchMarkDepthBias,
+ PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
+ / MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
+
///
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. selects
@@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
+ _gl.Uniform1(_locDepthBiasEyeCapN,
+ PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;
diff --git a/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs b/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs
new file mode 100644
index 00000000..8686c1c0
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Issue129PunchBiasTests.cs
@@ -0,0 +1,68 @@
+using System;
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #129 — doors/doorways leak through terrain and houses from over a landblock
+/// away. The punch's mark pass (#117, AD-18) biased the aperture fan toward
+/// the viewer by a CONSTANT 0.0005 NDC. NDC depth is non-linear: a constant
+/// NDC bias b spans ≈ b·d²·(f−n)/(f·n) meters of eye depth at eye distance d
+/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
+/// occluders in front of an aperture passed the mark and were far-Z punched:
+/// the door-shaped leak. The fix caps the bias's eye-space span
+/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
+/// below the ~10 m crossover, never more than the cap beyond it.
+///
+public class Issue129PunchBiasTests
+{
+ private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear)
+ private const float Far = 5000f;
+
+ /// Eye-depth span (meters) covered by an NDC depth bias b at eye
+ /// distance d: ndc(d) = f(d−n)/((f−n)d) ⇒ d(ndc) inverse ⇒
+ /// span = b·d²·(f−n)/(f·n) (exact for small b via the derivative).
+ private static float EyeSpanMeters(float biasNdc, float d) =>
+ biasNdc * d * d * (Far - Near) / (Far * Near);
+
+ [Fact]
+ public void OldConstantBias_SpansMetersAtALandblock_TheLeak()
+ {
+ // The refuted form (documentation of WHY the constant was wrong):
+ // 0.0005 NDC at ~one landblock spans far more eye depth than any
+ // occluder separation — everything in front got punched.
+ Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f);
+ // ...while at close range it was a sane sliver:
+ Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f);
+ }
+
+ [Fact]
+ public void CappedBias_MatchesValidatedConstant_AtCloseRange()
+ {
+ // Below the crossover the T5-validated constant must win unchanged —
+ // this preserves the #108 grass coverage bit-for-bit.
+ foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f })
+ Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6);
+ }
+
+ [Fact]
+ public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance()
+ {
+ for (float d = 1f; d <= 400f; d += 1f)
+ {
+ float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d);
+ Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f,
+ FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m"));
+ }
+ }
+
+ [Fact]
+ public void CappedBias_At200m_CannotReachOccluders()
+ {
+ // The reported #129 distance: occluder separations are tens of
+ // meters; the punch reach must stay under the 0.5 m cap.
+ float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f);
+ Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m"));
+ }
+}