fix #130 (the real strip): drawn-shell lift vs draw-space portal consumers

The user's re-gate refuted the scissor fix as THE strip (6c4b6d6 was a
real but sub-pixel under-coverage): the strip survived, screenshot at a
doorway, full width of the opening, top edge only, "very subtle".

Root cause (pinned by Issue130DoorwayStripTests.UnliftedGate_*): the
+0.02 m shell render lift. Cell shells DRAW 2 cm above the dat origin
(z-fight vs coplanar terrain); f35cb8b (the #119-residual fix,
2026-06-11) deliberately reverted the VISIBILITY graph to the physics
(unlifted) transform - but the OutsideView color gate (terrain/sky/
scissor through the doorway) and the seal/punch depth fans are
DRAW-space consumers and kept projecting the unlifted polygons. The
drawn lintel therefore sits one lift-projection above the gate's top
edge - measured 6.7 px at a 2.4 m doorway - and that band never
receives terrain/sky color while the seal also stamps 2 cm low.
A regression from f35cb8b, NOT from the W=0 clip port (987313a stays
exonerated). Vertical aperture edges are immune (the lift slides them
along themselves) - top edge only, exactly as reported; explains the
"also NOW" timing precisely.

Fix - draw space draws lifted, visibility stays physics (the f35cb8b
invariant, now symmetric):
- PortalVisibilityBuilder.Build gains drawLiftZ: the exit-portal branch
  projects the OutsideView region with the lifted transform; flood
  admission, side tests, and CellViews are untouched (default 0 keeps
  every existing visibility test bit-identical).
- The seal/punch fans (DrawRetailPViewPortalDepthWrite) lift their
  world verts to the drawn shell's space.
- One shared constant PortalVisibilityBuilder.ShellDrawLiftZ feeds the
  shell registration (GameWindow:5604), the gate, and the fans.

Register: AP-32 ADDED - the +0.02 lift had NO row (a pre-register
deviation the 2026-06-12 sweep missed). The row records the split
invariant both ways: a draw-space consumer that forgets the lift
re-opens the #130 strip; a visibility consumer that picks the lifted
transform re-opens the #119-residual side-cull.

Pins: the lifted gate covers the drawn (lifted) aperture to 0.00 px
across the 147-combo sweep; the unlifted gate shows the 6.7 px strip
(sensitivity proof - if the lift is ever removed, this test says the
drawLiftZ plumbing can go too).

Suites: App 257+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user re-gate at a doorway with the lintel on screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 14:28:16 +02:00
parent 4ba714835d
commit 5135066733
6 changed files with 191 additions and 38 deletions

View file

@ -4514,45 +4514,50 @@ cap constant (0.5 m) is the tuning knob — see AD-18.
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
**Status:** FIX SHIPPED — awaiting user visual gate
**Status:** FIX 2 SHIPPED — awaiting user visual re-gate
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — doorway-slice scissor box math (AD-17 family)
**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32)
**Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening.
of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`)
— user screenshot 2026-06-12 evening, "very subtle".
**Root cause (pinned headlessly 2026-06-12, `Issue130DoorwayStripTests`
— 147 eye/gaze combos at the real A9B4 0x0170 exit door):** the
`BeginDoorwayScissor` NDC→pixel conversion (`Floor(origin) +
Ceiling(size)`) put the box's far edge at `floor(min)+ceil(maxmin)` —
up to ONE PIXEL SHORT of the true top/right edge at unlucky fractional
alignments. The scissor brackets the ENTIRE landscape slice (sky,
terrain, statics, weather), the seal stamps the full aperture at true
depth, and the shell ends at the aperture edge — so the cut pixel row
never receives color: a background strip along the top edge that comes
and goes as the eye moves (alignment shifts). Captured live by the
harness: top edge y=0.7938 at 1080p → row 968 cut; right edge column
1296 cut at 1920. This violated AD-17's own doctrine (over-inclusion
safe, under-inclusion is the bug class).
**Root cause (the REAL strip, pinned by
`Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`):
the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat
origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual
fix) the visibility graph deliberately uses the PHYSICS (unlifted)
transform — but the OutsideView color gate and the seal fans, which are
DRAW-space consumers, kept the unlifted polygons. The drawn lintel
therefore sits one lift-projection ABOVE the gate's top edge —
**6.7 px at a 2.4 m doorway** (measured) — and that band gets no
terrain/sky color while the seal also stamps 2 cm low. Regression from
`f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are
immune (the lift slides them along themselves) — top edge only, exactly
as reported.
**Lead 1 REFUTED:** the W=0 clip port `987313a` is exonerated by the
same harness — the CPU polygon pipeline (ProjectToClip → ClipToRegion
merges → ClipPlaneSet planes) is sub-pixel exact against the raw
aperture projection (worst 0.54 px; 0.00 px in the aligned case). For
an all-in-front doorway polygon the port is bit-identical to the old
path by construction (the W clip pass only runs when a vertex has
w < 0).
**Fix 2:** draw-space consumers re-apply the lift —
`PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal
OutsideView region with the lifted transform (flood admission, side
tests, CellViews stay physics-space per f35cb8b), and the seal/punch
fans lift their world verts. One shared constant
`PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell
registration, the gate, and the fans. AP-32 register row added (the
lift had no row). Pins: the lifted gate covers the drawn aperture to
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
strip (sensitivity).
**Fix:** conservative outer bound `floor(min)/ceil(max)` extracted to
`NdcScissorRect.ToPixels` (GL-free, unit-tested); `BeginDoorwayScissor`
delegates. Pins: `NdcScissorRectTests` (containment property + both
captured alignments) + `Issue130DoorwayStripTests` (scissor never cuts
plane-admitted fragments; CPU-pipeline exactness canary ≤1.2 px).
**Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor
`Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges;
now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine).
The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact
in like-for-like space).
**Gate:** stand inside any cottage, look out the door, sweep the gaze —
no background strip at the top edge at any alignment.
**Gate:** stand inside, look out the door with the lintel on screen,
sweep the gaze — no background strip at the top edge at any alignment
or distance.
---

View file

@ -92,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 31 rows
## 3. Documented approximation (AP) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -127,6 +127,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
---

View file

@ -5599,9 +5599,13 @@ public sealed class GameWindow : IDisposable
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Keep the small render lift out of physics; retail BSP
// contact planes use the EnvCell origin verbatim.
// contact planes use the EnvCell origin verbatim. The lift
// constant is shared with every draw-space consumer of
// portal polygons (OutsideView gate, seal/punch fans) —
// see PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
@ -9699,9 +9703,16 @@ public sealed class GameWindow : IDisposable
if (localVerts.Length < 3)
continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
}

View file

@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
}
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
public static PortalVisibilityFrame Build(
LoadedCell cameraCell,
Vector3 cameraPos,
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null)
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
{
var frame = new PortalVisibilityFrame();
if (cameraCell == null) return frame;
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
}
// Exit portal -> outdoors visible through this (clipped) opening.
AddRegion(frame.OutsideView, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
continue;
}

View file

@ -54,7 +54,9 @@ public sealed class RetailPViewRenderer
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection);
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via

View file

@ -77,9 +77,16 @@ public class Issue130DoorwayStripTests
Assert.True(exitIdx >= 0, "0x0170 has no exit portal polygon");
var localPoly = root.PortalPolygons[exitIdx];
// DRAWN space: the shell that rasterizes the aperture (and the seal fan)
// draws +ShellDrawLiftZ above the physics transform — the gate must be
// compared against the drawn hole, not the physics polygon (#130: the
// unlifted gate left a 2 cm background strip under the drawn lintel).
var worldPoly = new Vector3[localPoly.Length];
for (int i = 0; i < localPoly.Length; i++)
{
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
}
Vector3 centroid = Vector3.Zero;
foreach (var w in worldPoly) centroid += w;
@ -137,7 +144,8 @@ public class Issue130DoorwayStripTests
for (int i = 0; i < clip.Length; i++)
aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj);
var pv = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
if (asm.OutsideViewSlices.Length == 0)
{
@ -194,6 +202,103 @@ public class Issue130DoorwayStripTests
$"plane gate under-covers the aperture top edge by {worstPlaneGapPx:F2}px @ {worstDesc}"));
}
/// <summary>Sensitivity proof + regression documentation: a gate built in
/// PHYSICS space (drawLiftZ 0) against the DRAWN (lifted) aperture shows a
/// multi-pixel strip at a close doorway — the user-visible #130 strip
/// (f35cb8b split the lift out of the visibility transform; the OutsideView
/// kept gating drawn color in unlifted space). If this stops failing-by-gap,
/// the lift is gone and the production drawLiftZ plumbing can go too.</summary>
[Fact]
public void UnliftedGate_LeavesTheStripAtTheDrawnTopEdge()
{
var datDir = CornerFloodReplayTests.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cells = CornerFloodReplayTests.LoadBuilding(dats);
var root = cells[ExitCellId];
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out var c) ? c : null;
int exitIdx = -1;
for (int i = 0; i < root.Portals.Count; i++)
{
if (root.Portals[i].OtherCellId == 0xFFFF && i < root.PortalPolygons.Count
&& root.PortalPolygons[i].Length >= 3)
{ exitIdx = i; break; }
}
Assert.True(exitIdx >= 0);
var localPoly = root.PortalPolygons[exitIdx];
var worldPoly = new Vector3[localPoly.Length];
Vector3 centroid = Vector3.Zero;
for (int i = 0; i < localPoly.Length; i++)
{
worldPoly[i] = Vector3.Transform(localPoly[i], root.WorldTransform);
worldPoly[i].Z += PortalVisibilityBuilder.ShellDrawLiftZ; // drawn space
centroid += worldPoly[i];
}
centroid /= worldPoly.Length;
var plane = root.ClipPlanes[exitIdx];
var worldNormal = Vector3.TransformNormal(plane.Normal, root.WorldTransform);
var cellCenterWorld = Vector3.Transform(
(root.LocalBoundsMin + root.LocalBoundsMax) * 0.5f, root.WorldTransform);
if (Vector3.Dot(worldNormal, cellCenterWorld - centroid) < 0)
worldNormal = -worldNormal;
worldNormal = Vector3.Normalize(worldNormal);
// d=2.4 m, eye low (0.9 m above the opening's base), gaze at the
// centroid — the main sweep's clean case, where the aperture top edge
// projects ON SCREEN (y≈0.79; a closer/higher eye pushes the lintel
// past the screen top and the seam becomes unmeasurable).
var eye = centroid + worldNormal * 2.4f;
eye.Z = centroid.Z - 1.0f + 0.9f;
var viewProj = ViewProjFor(eye, centroid);
var clip = new Vector4[worldPoly.Length];
for (int i = 0; i < worldPoly.Length; i++)
clip[i] = Vector4.Transform(new Vector4(worldPoly[i], 1f), viewProj);
var aperture = new Vector2[clip.Length];
for (int i = 0; i < clip.Length; i++)
aperture[i] = new Vector2(clip[i].X / clip[i].W, clip[i].Y / clip[i].W);
var pvUnlifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj); // drawLiftZ 0
var asmUnlifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvUnlifted);
Assert.True(asmUnlifted.OutsideViewSlices.Length > 0);
(float unliftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmUnlifted.OutsideViewSlices, 1920, 1080);
var pvLifted = PortalVisibilityBuilder.Build(root, eye, Lookup, viewProj,
buildingMembership: null, drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
var asmLifted = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pvLifted);
Assert.True(asmLifted.OutsideViewSlices.Length > 0);
(float liftedGapPx, _, _) = MeasureTopEdgeGap(aperture, asmLifted.OutsideViewSlices, 1920, 1080);
_out.WriteLine(FormattableString.Invariant(
$"top-edge gap vs the DRAWN aperture at d=2.4 m: unliftedGate={unliftedGapPx:F2}px liftedGate={liftedGapPx:F2}px"));
var dbg = new System.Text.StringBuilder(" aperture(LIFTED):");
foreach (var v in aperture) dbg.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(dbg.ToString());
foreach (var poly in pvUnlifted.OutsideView.Polygons)
{
var sb = new System.Text.StringBuilder(" unliftedGatePoly:");
foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(sb.ToString());
}
foreach (var poly in pvLifted.OutsideView.Polygons)
{
var sb = new System.Text.StringBuilder(" liftedGatePoly:");
foreach (var v in poly.Vertices) sb.Append(FormattableString.Invariant($" ({v.X:F4},{v.Y:F4})"));
_out.WriteLine(sb.ToString());
}
// The strip the user saw: physics-space gate vs drawn hole, several px.
Assert.True(unliftedGapPx > 2.0f, FormattableString.Invariant(
$"expected the unlifted gate to show the strip (>2px), got {unliftedGapPx:F2}px"));
// The fix: a gate in drawn space covers the drawn hole.
Assert.True(liftedGapPx <= 1.2f, FormattableString.Invariant(
$"lifted gate still under-covers by {liftedGapPx:F2}px"));
}
private static string DescribePolys(CellView view)
{
var parts = new List<string>();