From b5f2bf2b8fd432d0c4ab871c194e6217e716c519 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 31 May 2026 10:32:52 +0200 Subject: [PATCH] =?UTF-8?q?research(render):=20Phase=20U.4c=20=E2=80=94=20?= =?UTF-8?q?DISPROVE=20the=20side-test=20fix=20(PortalSide=20port=20is=20a?= =?UTF-8?q?=20no-op)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InitCell decode (PortalFlags.PortalSide=0x2) + a swept-pose A8CellAudit comparison (O=centroid, A=winding-corrected PortalSide, B=opposite) over the real flap cells. A is IDENTICAL to O at every pose/every portal — the (Flags&2)==0 boolean convention makes the dat PortalSide sense equal to our centroid sense, so swapping is a no-op and cannot fix the flap. B culls true-interior poses (wrong polarity). Conclusion: the flap is NOT the side-test sense — it's the 3rd-person camera eye crossing an interior portal plane while FindCameraCell still roots in the cell; ANY plane-side test culls there. No production code changed (no no-op shipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-u4c-initcell-pseudocode.md | 162 ++++++++++++++++++ tools/A8CellAudit/Program.cs | 60 +++++-- 2 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 docs/research/2026-05-31-u4c-initcell-pseudocode.md diff --git a/docs/research/2026-05-31-u4c-initcell-pseudocode.md b/docs/research/2026-05-31-u4c-initcell-pseudocode.md new file mode 100644 index 0000000..5b67312 --- /dev/null +++ b/docs/research/2026-05-31-u4c-initcell-pseudocode.md @@ -0,0 +1,162 @@ +# U.4c — PView::InitCell portal-side pseudocode (Task U.4c-3 oracle note) + +**Decomp anchor:** `docs/research/named-retail/acclient_2013_pseudo_c.txt:432896` +(`PView::InitCell`), sidedness classification at 432928-432975; the consumer gate +`PView::ClipPortals` at 433589. Struct layout from `acclient.h`: +`CCellPortal` (~32300) `{ other_cell_id; other_cell_ptr; portal /*CPolygon* */; portal_side; other_portal_id; exact_match }` +(stride 0x18, matches InitCell's `edx_1 += 0x18`); `portal_info` (~32458) `{ int seen; int inflag }`. + +`PortalFlags.PortalSide = 0x2`, `ExactMatch = 0x1` — confirmed by reflection +against `Chorizite.DatReaderWriter 2.1.7` (the version the audit tool + app use): +``` +PortalFlags.ExactMatch = 1 (0x1) +PortalFlags.PortalSide = 2 (0x2) +``` +Our physics-side `PortalInfo.PortalSide` already decodes this as a BOOLEAN with +INVERTED polarity: `PortalSide(bool) = (Flags & 2) == 0` (TRUE when the bit is +clear). This matches ACE `EnvCell.find_transit_cells` and the existing pseudocode +doc `acclient_indoor_transitions_pseudocode.md:291`. + +## What InitCell actually computes (per portal) + +`Render::FrameCurrent[0..2]` is the viewer position in the cell's pushed +coordinate frame (`positionPush(3, &ebp->pos)` at 432906 → viewer expressed in +cell-local space). `ecx_4 = portal->portal` is the `CPolygon*`; `ecx_4[8..0xb]` +are the portal-plane coefficients `(a, b, c, d)`. + +``` +D = FrameCurrent.z*c + FrameCurrent.y*b + FrameCurrent.x*a + d + = dot(viewerLocal, planeNormal) + planeD // signed distance, viewer→plane +eps = 0.000199999995f // ~2e-4 + +// 432938-432969: a 3-way classification of D vs ±eps, then a compare to portal_side. +// (Decoded from the x87 status-word idiom: byte[1] bit0=C0 (D +eps : clearly IN FRONT + eax_9 = 0; // 432959 +compare: + if (eax_9 == portal.portal_side) // 432962 (raw int field 0/1) + portal.inflag = 0; var_4 = 1; // 432964 + else + portal.inflag = 1; // 432968 +} +``` + +`ClipPortals` (433589) traverses a portal iff **`seen != 0 && inflag != 1`**. +NOTE: `seen` is set elsewhere (entry portal at 432973-432974; `AddViewToPortals`/ +`AddToCell`/`SetOtherSeen` on neighbour discovery) — InitCell here primarily writes +`inflag`. The BN field-write polarity for InitCell's local `seen`/`inflag` toggling +is genuinely ambiguous in the pseudo-C (the `*(uint32_t*)esi` base aliases +`portal.data`, and the entry-portal vs other-portal cases write both fields), and +mis-decoding it is exactly the #98 trap. **We therefore DO NOT hinge the port on the +exact BN write polarity.** + +## What we port (robust, cross-checked, polarity-resolved by real data) + +Three things are unambiguous and cross-checked against THREE independent sources +(InitCell structure; ACE `find_transit_cells` as ported in our own +`CellTransit.cs:161`; `acclient_indoor_transitions_pseudocode.md:305-314`): + +1. **Signed distance** `D = dot(viewerLocal, planeN) + planeD` (we already compute + this in `CameraOnInteriorSide`; the plane geometry is fine). +2. A **near-plane band is always traversable** (the on-plane `eax_9=1` branch + the + `±eps` tolerance). This is what kills the flap: a camera a few cm either side of + the opening is in/near the band and never spuriously culled. +3. The traverse/cull SENSE is keyed on the **authored `PortalSide`** bit, NOT a + centroid guess. + +### The sense (resolved against real dat — winding-corrected) + +Boolean `PortalSide = (Flags & 2) == 0` (our existing convention, matching ACE / +`PortalInfo.cs:44`). The doc-§314 intuition says "`PortalSide=true` ⇒ interior is the +POSITIVE half-space (dist>0 is inside the cell)", which would give +`PortalSide ? (D > -eps) : (D < +eps)`. **But that intuition assumes ACE/retail's +stored plane-normal orientation; our hydration normal is `Cross(p1-p0, p2-p0)` +(GameWindow:5641), whose winding is FLIPPED relative to ACE's for these cells.** The +real-dat evidence proves it: for `0171→0170` (`N=(0,-1,0)`, `d=-7.65`) the cell's own +centroid is at `centroidDot = -4.400` (NEGATIVE side) yet `datPortalSide=0` +(`PortalSide`-bool=true). So for OUR winding, `PortalSide=true` ⇒ interior is the +NEGATIVE half. The sign-correct traverse test is the INVERSE of the doc intuition: + +``` +traverse(portal, D) = + PortalSide ? (D < +eps) // OUR winding: interior = negative half → cull only when clearly on positive (far) side + : (D > -eps) // interior = positive half → cull only when clearly on negative (far) side +eps = 0.01f // PortalSideEpsilon, the existing builder tolerance (≈ retail's near-plane band, scaled to our units) +``` + +Genuinely back-facing is still culled: a camera clearly on the FAR side +(|D| >> eps, wrong sign) fails the inequality → CULL. The ±eps band keeps a camera +sitting a few cm either side of the opening traversable → no flap. + +**Real-dat verification (`A8CellAudit portals`, camera = cell centroid + a swept +pose toward/just past each portal plane).** Sign-correct ONLY IF every interior pose +traverses ALL the camera cell's portals (neighbours, incl. the window/exit cell, +stay reachable). With the formula above, ALL of `0171`'s three portals AND `0170`'s +two portals (incl. its `0xFFFF` exit) traverse from interior poses: + +| from→portal | N | d | centroidDot | datPortalSide | PortalSide(bool) | traverse@centroid | +|---|---|---|---|---|---|---| +| 0171→0170 | (0,-1,0) | -7.65 | -4.400 | 0 | true → `D<+eps` | -4.40 < eps → **TRAVERSE** | +| 0171→0173 | (-1,0,0) | +4.10 | -2.450 | 0 | true → `D<+eps` | -2.45 < eps → **TRAVERSE** | +| 0171→0175 | (0,0,-1) | -0.00 | -4.600 | 0 | true → `D<+eps` | -4.60 < eps → **TRAVERSE** | +| 0170→EXIT | (0,-.99,.10) | -8.56 | -0.346 | 0 | true → `D<+eps` | -0.35 < eps → **TRAVERSE** | +| 0170→0171 | (0,-1,0) | -7.65 | +0.480 | 1 | false → `D>-eps` | +0.48 > -eps → **TRAVERSE** | + +## ⚠️ FINDING — the PortalSide swap is a NO-OP for the flap (BLOCKED 2026-05-31) + +The swept-pose `A8CellAudit portals` run (camera lerped from cell centroid toward the +nearest portal vertex and a bit past the plane, t∈[0,1.3]) was meant to confirm the +sense. It instead falsified the whole premise of this fix. For EVERY portal of the +flap cells, three candidate senses were evaluated per pose: + +- **O** — OLD centroid `InsideSide` (pre-fix `CameraOnInteriorSide`) +- **A** — winding-corrected `PortalSide` (the formula above) +- **B** — doc-§314 literal `PortalSide` (opposite polarity) + +``` +0171→0170 N=(0,-1,0) d=-7.65 ourInsideSide=1 datPortalSide=0 + t0.00:D=-4.40/O=T/A=T/B=x t0.25:D=-3.30/O=T/A=T/B=x ... t1.00:D=0.00/O=T/A=T/B=T + t1.15:D=0.66/O=x/A=x/B=T t1.30:D=1.32/O=x/A=x/B=T +0170→0171 N=(0,-1,0) d=-7.65 ourInsideSide=0 datPortalSide=1 + t0.00:D=0.48/O=T/A=T/B=x ... t1.00:D=0.00/O=T/A=T/B=T t1.15:D=-0.07/O=x/A=x/B=T +``` +(All of 0171's three portals + 0170's two + 0174 + 0175 show the same pattern.) + +Two facts kill the side-test fix: + +1. **A ≡ O at every pose, every portal.** The "anti-correlation" the characterization + doc saw is in the RAW stored values (`InsideSide=1` vs `datPortalSide=0`); but each + value maps to the SAME inequality direction, so the centroid sense and the + winding-corrected `PortalSide` sense make the **identical** traverse/cull decision. + Swapping centroid `InsideSide` → dat `PortalSide` is a literal no-op for these cells + and **cannot fix the flap.** +2. **B culls true-interior poses** (`B=x` at t0.00, the cell centroid) → the doc-literal + polarity is wrong for our winding. So there is no viable alternative polarity either. + +**Root cause of the flap is NOT the side-test sense.** It is structural: set membership +is rebuilt every frame from a plane-side test against the 3rd-person camera EYE +(`camPos`, GameWindow:7290), which legitimately CROSSES interior portal planes while +`FindCameraCell` still roots the camera in the cell (AABB + grace frames). Once the eye +is clearly on the far side of the `0171→0170` plane (D > +eps, poses t1.15+), ANY +plane-side test — centroid or `PortalSide`, either polarity — culls the portal, drops +`0170`, empties `OutsideView`, and flaps terrain/shells off. The audit proves no sense +choice avoids this. + +**The retail-faithful fix is PVS / stab_list grounding** (spec §5.2 H1; U.4 handoff +`2026-05-30-phase-u4-shipped-and-flap-handoff.md` lines 91-115): seed every cell in the +camera cell's `stab_list` (`add_views` / `grab_visible_cells`, decomp 433382 / 311878) +as a participant BEFORE the per-frame walk, so the window/exit cell stays in the set +regardless of the side test. The side test then only refines WHERE each cell draws, +never WHICH cells exist. That is the spec's Layer 2 — out of scope for the task's +"swap the side test in the builder" framing, which is why this is reported BLOCKED. + +`ClipPlanes[i].Normal`/`D` remain correct (plane geometry is fine); `InsideSide` +remains on the struct. The builder side test is left UNCHANGED — no no-op swap shipped. diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index ddc13dd..2a643df 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -189,11 +189,20 @@ 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. + // U.4c side-test validation (supersedes the earlier `retailTraversesAtCentroid` + // column, which the characterization doc flagged UNRELIABLE). Builds the portal + // plane EXACTLY as GameWindow.BuildLoadedCell (Cross(p1-p0,p2-p0), d=-dot(N,p0)), + // derives the OLD centroid InsideSide, reads the dat PortalSide, and runs THREE + // candidate side senses for a camera SWEPT from the cell centroid toward (and a + // bit past) the portal plane: + // O = OLD centroid sense (CameraOnInteriorSide pre-fix) + // A = PortalSide, winding-corrected (docs/research/2026-05-31-u4c-initcell-pseudocode.md) + // B = PortalSide, doc-§314 literal intuition (opposite polarity) + // FINDING (2026-05-31): for the real Holtburg cottage cells, A is BYTE-IDENTICAL + // to O at every pose (the PortalSide swap is a NO-OP for the flap), and B culls + // true-interior poses (wrong polarity). No side-test sense fixes the flap — the + // flap is the 3rd-person eye legitimately crossing the portal plane while still + // rooted in the cell, which any plane-side test culls. See the BLOCKED report. string sideText = "no-cellStruct"; if (cellStruct is not null && cellStruct.Polygons.TryGetValue(portal.PolygonId, out var sp) && sp.VertexIds.Count >= 3 @@ -210,19 +219,40 @@ static void DumpCellPortals(DatCollection dats, uint envCellId) { 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; + int ourInsideSide = centroidDot >= 0 ? 0 : 1; // GameWindow.cs:5648 + bool portalSide = !portal.Flags.HasFlag(PortalFlags.PortalSide); // PortalInfo.cs:44 bool + int datPortalSide = portalSide ? 0 : 1; // raw bit (0 = bool true) + + const float eps = 0.01f; // PortalVisibilityBuilder.PortalSideEpsilon + // Nearest portal-polygon vertex = the plane-ward sweep anchor. + var pnear = s0; float bestD = centroidDot; + foreach (var vid in sp.VertexIds) + if (TryGetOrigin(cellStruct, (ushort)vid, out var vv)) + { + float vd = Vector3.Dot(n, vv) + d; + if (Math.Abs(vd) < Math.Abs(bestD)) { bestD = vd; pnear = vv; } + } + + static bool OldSense(int side, float dot, float e) => side == 0 ? dot >= -e : dot <= e; + static bool SenseA(bool ps, float dot, float e) => ps ? dot < e : dot > -e; + static bool SenseB(bool ps, float dot, float e) => ps ? dot > -e : dot < e; + + bool oAll = true, aAll = true, bAll = true; + var trace = new List(); + foreach (float t in new[] { 0f, 0.25f, 0.5f, 0.75f, 1.0f, 1.15f, 1.3f }) + { + var cam = Vector3.Lerp(centroid, pnear, t); + float dot = Vector3.Dot(n, cam) + d; + bool os = OldSense(ourInsideSide, dot, eps); + bool a = SenseA(portalSide, dot, eps); + bool b = SenseB(portalSide, dot, eps); + if (!os) oAll = false; if (!a) aAll = false; if (!b) bAll = false; + trace.Add($"t{t:F2}:D={dot:F2}/O={(os ? "T" : "x")}/A={(a ? "T" : "x")}/B={(b ? "T" : "x")}"); + } 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")}"; + $"sweepTraversesAll[OLD={oAll} A={aAll} B={bAll}] [{string.Join(" ", trace)}]"; } Console.WriteLine(