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:
parent
fdeede8796
commit
b5f2bf2b8f
2 changed files with 207 additions and 15 deletions
162
docs/research/2026-05-31-u4c-initcell-pseudocode.md
Normal file
162
docs/research/2026-05-31-u4c-initcell-pseudocode.md
Normal 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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue