acdream/docs/research/2026-05-31-u4c-flap-characterization.md
Erik 8941d1e6e5 research(render): Phase U.4c — refute eye-crosses-plane; correct stale H2 note
A8CellAudit portals now dumps each cell's local AABB. Real flap cells: 0171 local
y in [-7.65, 1.15], 0170 in [-8.61, -7.65]; the 0171->0170 portal plane is at
y=-7.65 (0171's MIN boundary), no overlap. So an eye genuinely inside 0171 always
has side-test D<=0 -> always traverses 0171->0170; the side test cannot cull 0170
while the eye is in 0171. The flap therefore requires the eye OUTSIDE 0171 while
root is still 0171 (cache/grace/3rd-person camera) -> a camera-cell-resolution
issue, not the side test (H2, disproven) and not the per-frame PVS set (H1, in
doubt). Mechanism still unconfirmed -> needs a live eye-pos capture. Stale H2
conclusion in the characterization note corrected with a banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:36:30 +02:00

7.3 KiB

U.4c flap characterization — real-dat evidence (Task U.4c-1)

⚠️ CORRECTION (2026-05-31, later same day) — the H2 conclusion below is WRONG. Two follow-on evidence steps overturned the "port the side test (H2)" conclusion this note reaches:

  1. PortalSide swap is a no-op (docs/research/2026-05-31-u4c-initcell-pseudocode.md): the dat PortalSide sense, under the (Flags & 2)==0 boolean convention + our normal winding, is byte-identical to our centroid InsideSide at every pose. The "anti-correlation" this note saw is in the raw 0/1 values, which cancel. Swapping changes nothing.
  2. The eye cannot cross the 0171→0170 plane while inside 0171 (real AABBs): 0171 local y ∈ [-7.65, 1.15]; the portal plane is at local y = -7.65 (0171's min boundary). The eye in 0171 always has side-test D ≤ 0 → always traverses. So the side test can only cull 0170 when the eye is OUTSIDE 0171 while root is still 0171 (cache/grace/3rd-person pull-back) — a camera-cell-resolution issue, not the side test and not the per-frame PVS set. Mechanism is NOT yet confirmed (needs live eye-position + root-cell + per-portal drop-reason at the flap frames). The raw dat data in this note (topology, planes, seenOutside=Y, PortalSide flags) is correct and still useful; only the H2 fix conclusion is retracted. See the pseudocode note for the disproof and the handoff at the session end for the convergence plan.

Date: 2026-05-31. Tool: tools/A8CellAudit/Program.cs portals subcommand (extended this session to dump per-portal plane + centroid-InsideSide + dat PortalSide). DAT-only, no live session.

TL;DR

The doorway flap is a direct 0xA9B40171 → 0xA9B40170 portal side-test flip — not a multi-hop PVS-reachability problem. The window/exit cell 0xA9B40170 is a direct portal neighbour of the camera cell 0xA9B40171. The root cause is H2 (confirmed): our per-frame side test (CameraOnInteriorSide) derives the sidedness sense from the cell centroid (GameWindow.BuildLoadedCell ~5648), independently of — and anti-correlated with — the dat's authored per-portal PortalSide flag, which retail's PView::InitCell (decomp:432896) uses. This supersedes the spec's leading hypothesis (H1, stab_list set-grounding): the stab_list/PVS grounding (Layer 1 data, already plumbed) remains a correct retail-faithful improvement and the seen_outside anchor is real, but the flap fix is the side-test port, not set-grounding.

Real cell topology (Holtburg cottage, landblock 0xA9B40000)

A8CellAudit portals 0xA9B40171 0xA9B40170 0xA9B40174 0xA9B40175:

0xA9B40171 (camera/root, seenOutside=Y) — 3 portals, all flags=ExactMatch, all BUILDER_SEES=OK:
  portal[0] -> 0xA9B40170   polyId=54   N=(0,-1,0)  d=-7.65  centroidDot=-4.400  ourInsideSide=1  datPortalSide=0
  portal[1] -> 0xA9B40173   polyId=55   N=(-1,0,0)  d= 4.10  centroidDot=-2.450  ourInsideSide=1  datPortalSide=0
  portal[2] -> 0xA9B40175   polyId=56   N=(0,0,-1)  d=-0.00  centroidDot=-4.600  ourInsideSide=1  datPortalSide=0

0xA9B40170 (window cell, seenOutside=Y) — 2 portals, BUILDER_SEES=OK:
  portal[0] -> 0xFFFF (EXIT/outdoor)  flags=5            polyId=4  N=(0,-0.99,0.10) d=-8.56 centroidDot=-0.346 ourInsideSide=1 datPortalSide=0
  portal[1] -> 0xA9B40171             flags=ExactMatch,PortalSide  polyId=5  N=(0,-1,0)       d=-7.65 centroidDot=+0.480 ourInsideSide=0 datPortalSide=1

0xA9B40174 (seenOutside=Y) -> 0xA9B40175
0xA9B40175 (seenOutside=Y) -> 0xA9B40171, -> 0xA9B40174

Findings

  1. Direct portal, not multi-hop. 0170 is 0171's portal[0]. The [vis] flap (cells=4 …0170 vs cells=3 without it, at stable root=0xA9B40171) is the 0171→0170 side test flipping as the camera moves within 0171. Multi-hop PVS grounding (H1) would not fix a direct-portal flip.

  2. PortalSide is an authored, direction-asymmetric dat flag. The same opening carries PortalSide UNSET on 0171→0170 and SET on the reverse 0170→0171. It is in portal.Flags (DatReaderWriter.Enums.PortalFlags.PortalSide), already surfaced into LoadedCell.Portals[i].Flags as (ushort)portal.Flags (GameWindow:5614-5618). Our builder never reads it — it uses ClipPlanes[i].InsideSide, which BuildLoadedCell derives from the cell centroid (centroidDot >= 0 ? 0 : 1, ~5648).

  3. Our InsideSide is anti-correlated with the dat PortalSide. For 0171→0170: InsideSide=1, PortalSide=0. For 0170→0171: InsideSide=0, PortalSide=1. We synthesize a sidedness sense (with our own cross-product normal winding) that is opposite to the authoritative dat value. A centroid guess is also structurally fragile for non-convex / oddly-placed portals and for the 3rd-person camera eye (which can sit on the far side of an interior portal plane while still rooted in the cell).

  4. Camera cell 0171 has seenOutside=Y. Retail keeps the landscape engaged while the camera is in 0171 (RenderNormalMode 92649; grab_visible_cells 311878). Terrain should not vanish from 0171. (Retail still gates the per-frame draw on outside_view non-empty in DrawCells 432715 — so the real fix is keeping 0170's exit in the per-frame view, i.e. fixing the side test, not floating terrain off outside_view.)

  5. All portal polygons resolve (BUILDER_SEES=OK). Not a degenerate-polygon bug.

The fix (evidence-selected: H2)

Port retail PView::InitCell (decomp:432896, sidedness at 432928-432968) sidedness into the builder's portal-traversal test: use the dat's authored PortalSide (from portal.Flags) for the traversal sense, replacing the centroid-derived InsideSide. The plane (Normal, D) still comes from the portal polygon geometry; only the sense source changes (centroid guess → authored bit).

Open detail — the exact sign-correct mapping (do NOT guess; port + validate). InitCell's condition is convoluted in the BN decomp (a "clearly behind ⇒ cull" branch, an on-plane branch, and if (computed_sidedness == portal_side) cull else traverse), and it interacts with the portal polygon's normal orientation/winding. The A8CellAudit retailTraversesAtCentroid column was a first-cut formula and is UNRELIABLE (it reported "no-traverse" for all of 0171's portals, which is absurd — proof the naive sense was wrong). Resolve the exact sense by reading InitCell carefully, porting it, and validating that (a) all of 0171's neighbours stay reachable from interior poses and (b) 0170 (and its exit) stays reachable across a camera sweep near the 0171→0170 plane — then the live visual gate. Keep Builder_BackFacingPortal_NotTraversed / sealed-cellar / window-narrowing tests faithful (a fix that just "always traverses" is a band-aid and must fail them).

Apparatus

A8CellAudit portals <cellIds...> now prints, per portal: plane N/d, centroidDot, our InsideSide, the dat PortalSide. Use it on any flap cell to see the centroid-vs-authored divergence. The committed Task-1 synthetic flap test (Build_NearBoundaryIntermediatePortal…) models the mechanism (a side-test flip drops a downstream exit cell) but via a multi-hop InsideSide cull; U.4c-3 should re-author it (or add a sibling) to faithfully model the direct-portal PortalSide-vs-centroid divergence, so the test is a clean gate for the actual fix.