diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 15a037ca..596c40b7 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -926,6 +926,11 @@ public sealed class GameWindow : IDisposable
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples,
+ // #117 (2026-06-11): the aperture punch's depth gate needs a
+ // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
+ // GLFW defaults to 8 stencil bits, but make the requirement
+ // explicit rather than platform-implicit.
+ PreferredStencilBufferBits = 8,
};
_window = Window.Create(options);
diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
index 809d6211..969396b8 100644
--- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
+++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs
@@ -9,17 +9,13 @@ namespace AcDream.App.Rendering;
/// writes — the port of D3DPolyRender::DrawPortalPolyInternal
/// (Ghidra 0x0059bc90, pc:424490).
///
-/// ⚠ RESERVED — NOT wired into the frame as of 2026-06-11. The
-/// first BR-2 attempt wired this as a seal (interior root) + punch (outdoor /
-/// look-in) and was reverted at the visual gate: the outdoor far-Z punch
-/// erased the depth of DYNAMIC objects (player / NPCs) standing in a door
-/// aperture, so the interior painted over them. The gate also proved #108
-/// (cellar grass-sweep) is a MEMBERSHIP bug, not a depth bug — the punch was
-/// only masking it on outdoor-classified cellar frames. The correct depth
-/// discipline (punch → interior → dynamics-last ordering) will be rebuilt
-/// during BR-3 when it can be verified against the shell-chop deletion. This
-/// class is the verified-correct depth-write primitive kept for that work; it
-/// has no callers today.
+/// Wired by T1 (BR-3, `579c8b0`): seal on interior roots, punch
+/// on outdoor / look-in roots, via GameWindow.DrawRetailPViewPortalDepthWrite
+/// (the DrawExitPortalMasks slice callback) — safe alongside the
+/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
+/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)
+/// added the two-pass stencil depth gate on the punch side — see
+/// .
///
/// Retail projects a portal polygon, software-clips it against the
/// installed portal view (polyClipFinish), and draws the survivor as a
@@ -52,10 +48,11 @@ public sealed class PortalDepthMaskRenderer : IDisposable
{
private const string VertSrc = @"#version 430 core
layout(location = 0) in vec3 aPos;
-uniform mat4 uViewProjection;
-uniform int uPlaneCount;
-uniform vec4 uPlanes[8];
-uniform int uForceFarZ;
+uniform mat4 uViewProjection;
+uniform int uPlaneCount;
+uniform vec4 uPlanes[8];
+uniform int uForceFarZ;
+uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
out float gl_ClipDistance[8];
void main()
{
@@ -64,6 +61,8 @@ void main()
gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0;
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)
gl_Position = clipPos;
}";
@@ -79,6 +78,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlaneCount;
private readonly int _locPlanes;
private readonly int _locForceFarZ;
+ private readonly int _locDepthBias;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@@ -103,6 +103,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount");
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
+ _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@@ -130,10 +131,39 @@ void main() { } // depth-only: color writes are masked off by the caller state
return s;
}
+ ///
+ /// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the
+ /// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because
+ /// retail's outdoor pass is painter's-ordered far→near (anything nearer
+ /// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame
+ /// has no such order, so an unconditional far-Z punch erased the depth
+ /// of NEARER occluders (terrain hills, closer buildings) at aperture
+ /// pixels — doors/interiors painted through them (the T5 #117 report).
+ /// The z-buffer-correct equivalent: punch ONLY where the aperture
+ /// polygon itself wins a depth test at its true depth (two-pass
+ /// 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;
+
///
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. selects
/// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth).
+ ///
+ /// Seal (interior root): one pass, retail-verbatim —
+ /// depth ALWAYS + true projected depth. It runs immediately after the
+ /// gated full depth clear, so there is no nearer content to stomp.
+ ///
+ /// Punch (outdoor root / look-in): two passes (#117).
+ /// Pass A marks stencil where the aperture fan passes a LEQUAL depth
+ /// test at its (biased) true depth — i.e. where the aperture is
+ /// actually visible against everything drawn so far. Pass B writes the
+ /// far-Z punch with depth ALWAYS but stencil-gated to the marked
+ /// pixels, and zeroes the stencil as it goes (self-cleaning). This is
+ /// the z-buffered equivalent of retail's painter's-order safety.
///
public void DrawDepthFan(
ReadOnlySpan worldVerts,
@@ -159,8 +189,6 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Disable(EnableCap.CullFace); // portal fans face either way
_gl.Disable(EnableCap.ScissorTest);
_gl.Enable(EnableCap.DepthTest);
- _gl.DepthFunc(DepthFunction.Always); // retail DEPTHTEST ALWAYS
- _gl.DepthMask(true); // z-write on
_gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
for (int i = 0; i < planeCount; i++)
_gl.Enable(EnableCap.ClipDistance0 + i);
@@ -183,13 +211,47 @@ void main() { } // depth-only: color writes are masked off by the caller state
fixed (float* pp = p)
_gl.Uniform4(_locPlanes, (uint)planeCount, pp);
}
- _gl.Uniform1(_locForceFarZ, forceFarZ ? 1 : 0);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v);
- _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
+
+ if (!forceFarZ)
+ {
+ // ── SEAL: retail-verbatim single pass ──
+ _gl.DepthFunc(DepthFunction.Always);
+ _gl.DepthMask(true);
+ _gl.Uniform1(_locForceFarZ, 0);
+ _gl.Uniform1(_locDepthBias, 0f);
+ _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
+ }
+ else
+ {
+ // ── PUNCH pass A: stencil-mark visible aperture pixels ──
+ _gl.Enable(EnableCap.StencilTest);
+ _gl.StencilFunc(StencilFunction.Always, 1, 0xFF);
+ _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
+ _gl.StencilMask(0xFF);
+ _gl.DepthFunc(DepthFunction.Lequal);
+ _gl.DepthMask(false);
+ _gl.Uniform1(_locForceFarZ, 0);
+ _gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
+ _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
+
+ // ── PUNCH pass B: far-Z write on marked pixels only;
+ // zero the stencil as we go (self-cleaning) ──
+ _gl.StencilFunc(StencilFunction.Equal, 1, 0xFF);
+ _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero);
+ _gl.DepthFunc(DepthFunction.Always);
+ _gl.DepthMask(true);
+ _gl.Uniform1(_locForceFarZ, 1);
+ _gl.Uniform1(_locDepthBias, 0f);
+ _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
+
+ _gl.Disable(EnableCap.StencilTest);
+ }
+
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
@@ -198,6 +260,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
for (int i = 0; i < planeCount; i++)
_gl.Disable(EnableCap.ClipDistance0 + i);
_gl.ColorMask(true, true, true, true);
+ _gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);