# 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.