research(render): Phase U.4c — DISPROVE the side-test fix (PortalSide port is a no-op)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-31 10:32:52 +02:00
parent fdeede8796
commit b5f2bf2b8f
2 changed files with 207 additions and 15 deletions

View file

@ -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<y), bit2=C2 (unordered),
// bit6=C3 (D==y). &0x41 = C0|C3 = D<=y ; &0x05 = C0|C2 = D< y (ordered).)
if (D <= +eps) { // 432941: not clearly in front
if (D < -eps) { // 432948 (!p_1 is false): clearly BEHIND the plane
portal.inflag = 0; var_4 = 1; // 432954 — handled, no side compare
// (seen was cleared to 0 at 432933 for non-entry portals)
} else { // -eps <= D <= +eps : ON the plane
eax_9 = 1; goto compare; // 432950
}
} else { // 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.

View file

@ -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<string>();
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(