From 13d58cae6a94eed5f3fb97903b9bb7abf833903a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 31 May 2026 10:13:16 +0200 Subject: [PATCH] =?UTF-8?q?research(render):=20Phase=20U.4c-1=20=E2=80=94?= =?UTF-8?q?=20characterize=20the=20flap=20on=20real=20dat=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A8CellAudit portals dump extended to print per-portal plane + centroid-derived InsideSide vs the dat's authored PortalSide. Real Holtburg cottage cells show: the flap is a DIRECT 0xA9B40171->0xA9B40170 portal side-test flip (0170 is a direct neighbour, not multi-hop), and our centroid-derived InsideSide is anti-correlated with the dat PortalSide that retail InitCell (432896) uses. Evidence selects H2 (port the side test) over H1 (PVS set-grounding). Camera cell 0171 seenOutside=Y. Full reading + fix direction + open sign question in the note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-u4c-flap-characterization.md | 90 +++++++++++++++++++ tools/A8CellAudit/Program.cs | 37 ++++++++ 2 files changed, 127 insertions(+) create mode 100644 docs/research/2026-05-31-u4c-flap-characterization.md diff --git a/docs/research/2026-05-31-u4c-flap-characterization.md b/docs/research/2026-05-31-u4c-flap-characterization.md new file mode 100644 index 0000000..bcb9e77 --- /dev/null +++ b/docs/research/2026-05-31-u4c-flap-characterization.md @@ -0,0 +1,90 @@ +# U.4c flap characterization — real-dat evidence (Task U.4c-1) + +**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 ` 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. diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index 9720a7a..ddc13dd 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -189,9 +189,46 @@ static void DumpCellPortals(DatCollection dats, uint envCellId) (missing.Count > 0 ? $" MISSING=[{string.Join(",", missing)}]" : ""); } + // U.4c characterization: compute the plane + centroid-derived InsideSide + // EXACTLY as GameWindow.BuildLoadedCell does, and compare its traversal + // SENSE against the dat's authored PortalSide flag (retail portal_side). + // If they disagree for a portal the camera should see through, that is the + // flap's root: our centroid guess culls where retail's authored bit traverses. + string sideText = "no-cellStruct"; + if (cellStruct is not null && cellStruct.Polygons.TryGetValue(portal.PolygonId, out var sp) + && sp.VertexIds.Count >= 3 + && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[0], out var s0) + && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[1], out var s1) + && TryGetOrigin(cellStruct, (ushort)sp.VertexIds[2], out var s2)) + { + var n = Vector3.Cross(s1 - s0, s2 - s0); + n = n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero; + float d = -Vector3.Dot(n, s0); + // Cell centroid = AABB center over all cellStruct verts (matches BuildLoadedCell). + var mn = new Vector3(float.MaxValue); var mx = new Vector3(float.MinValue); + foreach (var v in cellStruct.VertexArray.Vertices.Values) + { mn = Vector3.Min(mn, v.Origin); mx = Vector3.Max(mx, v.Origin); } + var centroid = (mn + mx) * 0.5f; + float centroidDot = Vector3.Dot(n, centroid) + d; + int ourInsideSide = centroidDot >= 0 ? 0 : 1; // GameWindow.cs:5648 + int datPortalSide = portal.Flags.HasFlag(PortalFlags.PortalSide) ? 1 : 0; + // Our test traverses when the camera is on the centroid's side of the plane. + // Retail (InitCell 432962) traverses when computed-sidedness != portal_side, + // where sidedness==1 means "in front" (dot>eps). At the centroid our test + // always traverses; the meaningful question is whether the centroid side + // (front if centroidDot>0) matches retail's traverse-sense for this portal. + int centroidSidedness = centroidDot > 0 ? 1 : 0; // 1=front + bool retailTraversesAtCentroid = centroidSidedness != datPortalSide; + sideText = + $"N=({n.X:F2},{n.Y:F2},{n.Z:F2}) d={d:F2} centroidDot={centroidDot:F3} " + + $"ourInsideSide={ourInsideSide} datPortalSide={datPortalSide} " + + $"retailTraversesAtCentroid={(retailTraversesAtCentroid ? "Y" : "n")}"; + } + Console.WriteLine( $" portal[{i}] other=0x{portal.OtherCellId:X4} -> {dest} " + $"flags={portal.Flags} polyId={portal.PolygonId} | {resolveText}"); + Console.WriteLine($" SIDES: {sideText}"); } Console.WriteLine(