fix(render): Phase U.2b — resolve reciprocal portal by other_portal_id (retail 433557)

Code review caught a CRITICAL under-inclusion: ApplyReciprocalClip scanned for the
first OtherCellId match, so a cell with two portals to the same neighbour clipped both
near-side openings against the FIRST reciprocal polygon — hiding geometry through the
second opening (real on Holtburg cellar cells 0x148<->0x149). Plumb the dat's
OtherPortalId back-link through CellPortalInfo + BuildLoadedCell and index the reciprocal
directly (retail arg2->other_portal_id, 433557). Skip (degrade to over-include) when the
index is unresolvable — never clip against a guessed polygon. Adds a disjoint two-back-
portal regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 16:56:00 +02:00
parent 3916b2b23e
commit 65781f5768
7 changed files with 184 additions and 66 deletions

View file

@ -146,21 +146,33 @@ Retail anchors (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `InitCell` — per-cell timestamp init
- `AddViewToPortals` 433446 — change-detection that drives the fixpoint
**Related M-4 stub — CLOSED (Phase U.2b, 2026-05-30):** the
neighbour-side `OtherPortalClip` (decomp:433524) is now ported. After a
portal's near-side opening is clipped against the current cell's view,
`PortalVisibilityBuilder.ApplyReciprocalClip` resolves the neighbour's
matching back-portal (scan `neighbour.Portals` for the entry whose
`OtherCellId` == the near cell's low-16-bits id; `PortalPolygons` is in
lockstep), projects it through the neighbour's `WorldTransform`, and
**Related M-4 stub — CLOSED (Phase U.2b, 2026-05-30; reciprocal-resolution
fix 2026-05-30):** the neighbour-side `OtherPortalClip` (decomp:433524) is
ported. After a portal's near-side opening is clipped against the current
cell's view, `PortalVisibilityBuilder.ApplyReciprocalClip` resolves the
neighbour's matching back-portal **by direct index via the dat's
`CellPortal.OtherPortalId` back-link** (retail `arg2->other_portal_id`,
005a54b2), projects it through the neighbour's `WorldTransform`, and
intersects it into the propagated region before the union — so a cell's
clip region is the intersection of the opening seen from BOTH sides. Can
only TIGHTEN; degrades to prior near-side-only behavior when no
back-portal is found. The `TODO(A8.F)` marker is removed. Covered by
clip region is the intersection of the opening seen from BOTH sides. The
reciprocal is `neighbour.PortalPolygons[portal.OtherPortalId]`, NOT a scan
for the first `OtherCellId` match. The direct index is load-bearing: a cell
with TWO portals to the same neighbour (real on the Holtburg cellar —
`0x148` has two portals to `0x149`, polys 40/41, and `0x149` has two
reciprocals back to `0x148`) clips each opening against its OWN reciprocal.
The earlier scan-by-first-match resolved both near-side openings to the
FIRST reciprocal, and disjoint apertures then intersected to empty —
HIDING the geometry through the second opening (under-inclusion). The fix
plumbs `OtherPortalId` through `CellPortalInfo` + `BuildLoadedCell`. Guards
degrade to over-include (never clip against a guessed polygon) when the
index is out of range, the polygon is missing/degenerate, or it projects
behind the camera. Can only TIGHTEN. Covered by
`PortalVisibilityBuilderTests.Build_AppliesReciprocalOtherPortalClip`
(reciprocal tightening) + `…_DegradesGracefully_WhenNoBackPortal`.
(The diamond-topology onward re-propagation of late growth remains out of
scope here — tracked under U.6.)
(reciprocal tightening) + `…_DegradesGracefully_WhenNoBackPortal`
(over-include degrade) + `…_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal`
(the disjoint two-back-portal regression). (The diamond-topology onward
re-propagation of late growth remains out of scope here — tracked under
U.6.)
**Files:**
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — replace the