acdream/docs/research/2026-06-11-holistic-map/wf1-interior-collision.md
Erik 5e2f99d08e docs: Phase A comparison + Phase B port plan (holistic building-render investigation)
Deliverable 1: docs/research/2026-06-11-building-render-acdream-vs-retail-
comparison.md - the acdream-vs-retail architecture comparison synthesized
from two ultracode mapping fan-outs (11/12 areas, ~90 agents, every retail
claim Ghidra/pc-cited, every acdream claim file:line, 40/76 divergences
adversarially verified so far; raw per-area evidence committed under
docs/research/2026-06-11-holistic-map/).

Headline findings: (1) retail flattens GfxObjs/cells at load exactly like
us (ConstructMesh + RemoveNonPortalNodes) - the MDI pipeline survives;
(2) the phantom/door mechanism is the skipNoTexture draw-time surface gate
(dat-confirmed); (3) retail never geometrically clips world geometry -
aperture exactness is a DEPTH discipline (punch maxZ1 / seal maxZ2 / gated
clear + far-to-near whole-mesh draws) - reframes #114; (4) flood admission
is already faithful, the trigger/depth/multi-view/cone-culling layers are
missing; (5) #115 root cause verified (boom damping severed from the
published collided viewer); collision A6.P4 design verified with
corrections (signed other_portal_id >= 0 gate).

Deliverable 2: docs/plans/2026-06-11-building-render-port-plan.md - the
phased port plan (BR-1 surface gate, BR-2 depth punch/seal, BR-3 delete
the shell chop, BR-4 draw-driven floods, BR-5 viewconeCheck, BR-6 one
gate, BR-7 collision A6.P4, BR-8 camera/lighting/LOD) with per-phase
acceptance criteria, bug closures, keep-list, and a playable-after-every-
phase migration order. AWAITING USER APPROVAL - no implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 05:54:12 +02:00

215 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Area 6 — Interior collision: per-cell shadow lists (#99, A6.P4)
## RETAIL
RETAIL ARCHITECTURE (all branch/gate claims verified in Ghidra decompile; pc:LINE given as cross-reference only).
== Data structures ==
Every cell — indoor EnvCell or outdoor LandCell — owns a per-cell object index: CObjCell {num_objects + object_list (objects whose m_position is in this cell), num_shadow_objects + shadow_object_list (objects whose GEOMETRY overlaps this cell), num_stabs + stab_list, restriction_obj} (acclient.h:30916-30936). The link record is CShadowObj {physobj, cell_id, cell} (acclient.h:30940-30944): one per (object, overlapped-cell) pair, stored inline in the object's own shadow_objects DArray (stride 0x18 — Ghidra 0x00514ae0) AND pointer-referenced from each cell's shadow_object_list. So "where can this object be collided with?" is answered ONCE, at registration, by writing the object into every cell it geometrically touches.
== Registration: which cells does a shadow land in? (named question 1) ==
The cell set is built by CObjCell::find_cell_list(Position, num_spheres, spheres, CELLARRAY, outCell, SPHEREPATH) — Ghidra 0x0052b4e0, pc:308742. Verified structure:
1. Reset array. If m_position is OUTDOOR (objcell_id & 0xFFFF < 0x100): CLandCell::add_all_outside_cells(pos, n, spheres, array) the outdoor 24 m land cells the spheres overlap (crosses landblock borders). If INDOOR: add exactly THAT one cell (and set path->hits_interior_cell=1).
2. Then a single forward loop over the GROWING array: for each cell already in it, call its virtual find_transit_cells (vtable+0x80). Because newly appended cells are themselves visited, this is a recursive flood — but every hop is gated by ACTUAL SPHERE OVERLAP, not by topology and not by any visibility list:
- CEnvCell::find_transit_cells (Ghidra 0x0052c820 — the verified binary anchor): for each CCellPortal: (a) exterior portal (other_cell_id == 0xFFFFFFFF): test each sphere against the portal polygon plane; if |signed distance| < radius + F_EPSILON (the sphere STRADDLES the doorway plane) set a flag; after the portal loop the flag triggers add_all_outside_cells this is how an indoor-positioned object near a street door also lands in outdoor cells. (b) interior portal, neighbor LOADED: add the neighbor IFF some sphere intersects the neighbor's actual cell geometry CCellStruct::sphere_intersects_cell != OUTSIDE. (c) interior portal, neighbor UNLOADED: add by cell-id with a NULL cell pointer IFF the sphere crosses the portal plane to the far side (the portal_side sign test this is the only place portal_side appears in this function).
- CLandCell::find_transit_cells (Ghidra 0x00533800): add_all_outside_cells, then CSortCell::find_transit_cells (Ghidra 0x00534060) which forwards to CBuildingObj::find_building_transit_cells (Ghidra 0x006b5230): for each CBldPortal (acclient.h:32094) get the interior EnvCell behind it and call CEnvCell::check_building_transit (Ghidra 0x0052c5d0): add the interior cell IFF other_portal_id >= 0 AND some sphere intersects that interior cell's BSP. THIS is the outdoor→indoor bridge: an outdoor-positioned door whose sphere pokes into the vestibule is written into the vestibule's shadow_object_list at registration. No reverse portal map exists or is needed — the building's own portal list carries the direction.
3. Spheres used: the object's per-part CylSpheres globalized (overload Ghidra 0x0052b9f0: sphere center = each CylSphere's low_pt transformed local→global, radius = cyl radius, capped at 10 spheres), falling back to the part-array sorting sphere (calc_cross_cells, Ghidra 0x00515230). So the flood reach is the object's REAL collision footprint — a fireplace deep in a room lands in 1 cell; a door at a threshold lands in 2-3.
4. Static placement variant calc_cross_cells_static (Ghidra 0x00515160, pc:283340) sets cell_array.do_not_load_cells=1, which (back in find_cell_list, Ghidra 0x0052b4e0 tail) prunes flood results down to {start cell} start cell's stab_list (num_stabs/stab_list, acclient.h:30930-30931) — a don't-force-load restriction, NOT the placement rule itself.
The write: CPhysicsObj::add_shadows_to_cells (Ghidra 0x00514ae0, pc:282819): one CShadowObj per array cell; if the array entry's cell pointer is null (unloaded), the shadow records cell_id but joins no list. Each loaded cell gets CObjCell::add_shadow_object (Ghidra 0x0052b280, pc:308584: append + back-link shadow->cell) plus CPartArray::AddPartsShadow. Particle emitters (PARTICLE_EMITTER_PS=0x1000, acclient.h:2829) take a single-cell shortcut (add_particle_shadow_to_cell); HAS_PHYSICS_BSP_PS=0x10000 objects (acclient.h:2833) use find_bbox_cell_list (Ghidra 0x00510fc0: current cell + CPartArray::calc_cross_cells_static over the growing array). Children recurse.
== Query: who is consulted at collision time? (named question 2) ==
Primary cell: CTransition::transitional_insert (pc:273137, 0x0050b6f0) → CTransition::insert_into_cell(sphere_path.check_cell, attempts) (pc:271991, Ghidra-region 0x00509e70) → check_cell->vtable->find_collisions(this), retried up to `attempts`. Per cell type (verified Ghidra): CEnvCell::find_collisions (0x0052c100) = find_env_collisions (own cell BSP, vtable+0x8c) THEN CObjCell::find_obj_collisions(this). CLandCell::find_collisions (0x00532d60) = find_env_collisions (terrain) THEN CSortCell::find_collisions (0x005340a0: this->building → CBuildingObj::find_building_collisions) THEN find_obj_collisions(this). Two structural facts fall out: (a) CObjCell::find_obj_collisions (Ghidra 0x0052b750, pc:308916) iterates ONLY this->shadow_object_list — skip parented objects, skip self, call CPhysicsObj::FindObjCollisions per object, first non-OK halts; entire loop skipped when insert_type == INITIAL_PLACEMENT_INSERT; (b) the BUILDING collision channel exists only on LandCell — an indoor primary cell structurally cannot collide with a building shell.
Other cells: CTransition::check_other_cells (pc:272690-272798, 0x0050ae50): rebuilds this->cell_array via find_cell_list(&cell_array, &pick, &sphere_path) from the CURRENT sphere positions (the same flood machinery as registration), then for each array cell != the primary calls vtable+0x88 find_collisions — i.e. env AND shadow-objects per other cell. COLLIDED/ADJUSTED return immediately; SLID clears contact_plane_valid/contact_plane_is_water and returns; afterwards check_cell is retargeted to the containing-cell pick (adjust_check_pos).
Straddling-doorway object: covered twice — it was REGISTERED into both cells (sphere-overlap flood), and the moving player's own cell_array spans both cells at the threshold so both lists are iterated anyway. There is no spatial radius anywhere in the query; the only sets are per-cell lists.
== Removal / update cadence (named question 3) ==
Always remove-all-then-add-all, never an incremental diff: CPhysicsObj::remove_shadows_from_cells (Ghidra 0x00511230): per shadow, CObjCell::remove_shadow_object (Ghidra 0x0052b2d0: swap-remove + DArray shrink) + CPartArray::RemoveParts; recurse children. Triggers, verified by xref + decompile:
- EVERY successful movement step: CPhysicsObj::SetPositionInternal(CTransition*) (Ghidra 0x00515330, pc:283270-283545) ends with: if (cell) { if (state & HAS_PHYSICS_BSP_PS) calc_cross_cells(); else if (transit->cell_array.num_cells > 0) { remove_shadows_from_cells(); add_shadows_to_cells(&transit->cell_array); } } — the movement path REUSES the transition's already-computed CELLARRAY (the one check_other_cells just built), so per-tick re-registration costs no extra flood.
- Placement/teleport: calc_cross_cells (Ghidra 0x00515230 = fresh find_cell_list + remove + add) from the non-transition SetPositionInternal overload (xref 0x0051551b) and ForceIntoCell (xref 0x0051568b).
- Cell load: CObjCell::init_objects (Ghidra 0x0052b420): CObjectMaint::InitObjCell + recalc_cross_cells (Ghidra 0x00515a30) for each non-static, not-completely-visible object homed in the newly loaded cell. Also set_parent (xrefs 0x00515b15/0x00515bab).
## ACDREAM
== acdream call chain ==
REGISTRATION — src/AcDream.Core/Physics/ShadowObjectRegistry.cs. Register (line 41): if cellScope != 0 the entry goes into exactly that ONE cell (lines 62-72, the A1.5 interior-statics fix); otherwise the entry is written into the outdoor 24 m grid cells its XY bounding box overlaps, clamped to a SINGLE landblock (lines 77-105: minCx/maxCx clamp 0..7, one lbPrefix). RegisterMultiPart (line 124) does the same per shape (lines 162-186). No portal traversal, no sphere-vs-cell-BSP test, no building bridge — the cell set is an XY rectangle. UpdatePosition (lines 219-278) re-registers via Deregister + Register on the 5-10 Hz server UpdatePosition stream. Production call sites: GameWindow.cs:3264 RegisterMultiPart for server-spawned entities (doors included) passes NO cellScope → doors register into outdoor grid cells only, even when their geometry pokes through a doorway into a vestibule; GameWindow.cs:6176/6246/6282/6307/6506 Register statics with cellScope = entity.ParentCellId ?? 0u (interior EnvCell statics → one cell; landblock-baked building-shell GfxObjs → landblock-wide outdoor footprint).
QUERY — src/AcDream.Core/Physics/TransitionTypes.cs. TransitionalInsert (line 862) runs FindEnvCollisions (line 876, primary cell env only) then ONE flat FindObjCollisions (line 900) per attempt — the comment at lines 856-859 explicitly concedes "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions ... is already a flat per-landblock query". FindObjCollisions (line 2307): queryRadius = sphereRadius + movement.Length() + 5f (line 2340 — a 5 m pad with no retail analog); CellTransit.FindCellSet supplies portalReachableCells (line 2358); ShadowObjectRegistry.GetNearbyObjects is called once with primaryCellId = sp.CheckCellId and isViewer (lines 2376-2382); results iterated with self-skip (line 2398) + a broad-phase distance pre-check (lines 2401-2409). GetNearbyObjects (ShadowObjectRegistry.cs:430): first iterates portalReachableCells lists (lines 460-471), then the b3ce505 stopgap gate — `if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;` (lines 494-495) — else a 9-landblock radial sweep over grid cells the query radius overlaps (lines 498-539). CheckOtherCells (TransitionTypes.cs:1632-1750, the A4 port of retail check_other_cells) iterates the FindCellSet set but runs ENV collision only (terrain ValidateWalkable for outdoor ids lines 1650-1684, cell BSP for indoor lines 1687-1750) — NO per-cell shadow-object query. RunCheckOtherCellsAndAdvance (lines 2158-2195) = FindCellSet (2181) → CheckOtherCells (2185) → containing-cell retarget (2192-2193).
CELL-SET MACHINERY — src/AcDream.Core/Physics/CellTransit.cs. FindTransitCellsSphere (line 107) is a faithful port of CEnvCell::find_transit_cells: exterior-portal straddle gate restored + live-binary verified (lines 130-176), loaded-neighbor sphere-vs-CellBSP test (lines 184-199); plus a NON-retail topology output hasExitPortal (lines 102-106, set at 132). BuildCellSetAndPickContaining (line 543): indoor seed = current cell at index 0 (line 580) + growing-array walk (lines 589-619) with the A6.P5 widening at lines 614-618 (any exit-portal cell → AddAllOutsideCells by TOPOLOGY, wider than retail's straddle gate, self-documented as a #99 stopgap); outdoor seed = AddAllOutsideCells + CheckBuildingTransit per building stab (lines 626-634 — the retail building bridge IS already ported here, just unused by registration).
== A6.P4 spec verdict (docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md) ==
CONFIRMED: §2.2 per-cell shadow_object_list + find_obj_collisions(this)-only iteration (Ghidra 0x0052b750); the decomp-anchor table's function identities all check out against Ghidra (308742→0x0052b4e0, 282819→0x00514ae0, 308584→0x0052b280, 308916→0x0052b750, 309560→0x0052c100, 316951→0x00532d60); §3.1's inversion (compute cell set at registration, strict per-cell query) is the right architecture; spec §7 Q2 answered yes — doors register outdoor-only (GameWindow.cs:3264, no cellScope).
REFUTED: §3.1/§3.3-slice-2's registration rule "indoor m_position: that cell + VisibleCellIds (forward portal traversal)" — retail does NOT use any visibility list for shadow placement. The recursion is sphere-overlap-gated portal flood (find_transit_cells: neighbor added only if the object's spheres intersect the neighbor's CCellStruct BSP, Ghidra 0x0052c820); stab_list appears only as the do_not_load_cells PRUNE in the static variant. Using VisibleCellIds would massively over-register (a fireplace would land in the whole room-chain PVS). Spec §7 Q1 is therefore moot — wrong list entirely.
REFUTED: §3.2's worry that outdoor→indoor needs a "reverse portal map" (option 3.2.a) — no reverse lookup exists in retail. The outdoor→indoor direction goes through the BUILDING: CLandCell::find_transit_cells → CSortCell.building → CBldPortal list → CEnvCell::check_building_transit (Ghidra 0x00533800/0x00534060/0x006b5230/0x0052c5d0). acdream already ports this exact bridge (CellTransit.cs:626-634) — slice 2 just has to invoke it from the registration-side cell-set builder.
ADJUSTED: §3.2.b ("query-side expansion ... matches retail behaviorally") shipped as the current portalReachableCells + A6.P5 widening, but it is NOT behaviorally equivalent to retail (topology-wide, plus the radial sweep persists for outdoor primaries); it is staging only, and slice 2/3 must land for retail parity, as the spec itself intends.
MISSING FROM SPEC (1): the movement-time trigger — retail re-registers every moved object each successful transition step by reusing the transition's CELLARRAY (SetPositionInternal, Ghidra 0x00515330 tail). The spec only discusses Register/UpdatePosition; the faithful port should refresh a moving entity's shadow set from its own transition cell set, answering spec §7 Q4 (cost ≈ zero — the set is already built).
MISSING FROM SPEC (2): buildings are not shadow objects in retail at all — the building channel hangs off LandCell::find_collisions only (CSortCell::find_collisions, Ghidra 0x005340a0/0x00532d60), so indoor cells can never see building shells. The spec keeps the cottage as an outdoor-registered shadow entry; the deeper port moves building shells out of the registry into a per-LandCell building reference (cache.GetBuilding exists, CellTransit.cs:631), which is what makes the #98 gate removable rather than merely relocated.
## DIVERGENCES
### [CRITICAL] registration-cell-set-not-portal-flood (confirmed) — Shadow registration uses an XY grid rectangle / single scoped cell instead of retail's sphere-overlap portal flood
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), every claimed gate verified:
1. Registration chokepoint confirmed. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) and calc_cross_cells_static (0x00515160) both build a CELLARRAY via CObjCell::find_cell_list, then call remove_shadows_from_cells + add_shadows_to_cells. Xrefs confirm these are THE registration paths: calc_cross_cells_static is called from CObjCell::init_static_objs, CEnvCell::init_static_objects, and CPhysicsObj::add_obj_to_cell; calc_cross_cells from SetPositionInternal, ForceIntoCell, recalc_cross_cells (movement). add_shadows_to_cells (0x00514ae0) writes one CShadowObj per CELLARRAY cell via CObjCell::add_shadow_object (0x0052b280), which appends to the cell's shadow_object_list and back-points shadow->cell — so the per-cell shadow list IS the registration result, exactly as claimed.
2. CObjCell::find_cell_list (0x0052b4e0): verified verbatim — outdoor seed (cell id low16 < 0x100) CLandCell::add_all_outside_cells; indoor seed CELLARRAY::add_cell(that one cell); then a growing-array walk (`while (i < cell_array->num_cells)` with num_cells re-read each iteration) calling each cell's vtable+0x80 (find_transit_cells). The CylSphere wrapper (0x0052b9f0) converts up to 10 CCylSpheres (low_pt global center, cylinder radius) into the sphere set confirming the claimed "CylSphere-derived sphere set".
3. CEnvCell::find_transit_cells (0x0052c820): verified per portal: other_cell_id == 0xFFFFFFFF (exterior) straddle test per sphere (-(F_EPSILON+radius) < planeDist < F_EPSILON+radius) sets a flag, and after the loop CLandCell::add_all_outside_cells fires if any portal straddled; loaded neighbor added IFF CCellStruct::sphere_intersects_cell(neighbor->structure, sphere) != OUTSIDE. All as claimed. One branch the claim omitted (does not weaken it): when the neighbor cell is NOT loaded (GetOtherCell null), retail still adds the neighbor's cell id from a portal-plane distance + portal_side test — a port must preserve this for streaming-in cells.
4. Outdoor→indoor bridge verified: CLandCell::find_transit_cells (0x00533800) = add_all_outside_cells + CSortCell::find_transit_cells (0x00534060) → if cell has a building → CBuildingObj::find_building_transit_cells (0x006b5230) → per CBldPortal → CEnvCell::check_building_transit (0x0052c5d0), whose gate is exactly `(-1 < other_portal_id)` AND at least one sphere with sphere_intersects_cell != OUTSIDE → add_cell(interior cell). So a doorway-spanning door's spheres reach the vestibule's shadow_object_list at registration. Claim verified.
5. One genuine refinement (folded into notes, not a contradiction): both calc_cross_cells variants branch on `state & 0x10000` (HAS_PHYSICS_BSP) → CPhysicsObj::find_bbox_cell_list (0x00510fc0) instead of the sphere flood. That path is the SAME portal-graph growing-array architecture but geometry = part bounding boxes: CPartArray::calc_cross_cells_static (0x00518160) → vtable+0x7c part-based CEnvCell::find_transit_cells (0x0052cae0), gating neighbor add on Plane::intersect_box (portal plane) + CCellStruct::box_intersects_cell, with the same exterior → add_all_outside_cells (part variant 0x00533360). There is also a particle path (`state & 0x1000` → add_particle_shadow_to_cell). Whether ACE-sent door PhysicsState carries 0x10000 was not settled here; either way the registration is a portal flood with geometry-vs-cell-BSP gates, never an XY grid — the divergence is identical under both branches, and the sphere-flood port shape proposed matches what retail does for all non-HAS_PHYSICS_BSP objects.
ACDREAM SIDE — all cited lines verified by reading the code:
- ShadowObjectRegistry.Register: cellScope!=0 → exactly ONE cell (ShadowObjectRegistry.cs:62-72); else 24m XY-grid rect from position±radius, clamped to cx/cy 0..7 of ONE landblock (ShadowObjectRegistry.cs:77-105). RegisterMultiPart repeats the identical per-shape logic (162-186). UpdatePosition re-registers through the same paths (219-278). No portal traversal, no sphere-vs-cell-BSP test anywhere in registration — confirmed by reading the whole file; the only flood machinery (CellTransit.FindCellSet) is consumed at QUERY time (GetNearbyObjects portalReachableCells param, ShadowObjectRegistry.cs:460-471), not registration.
- Production call sites confirmed: server-spawned entities (doors included) register via RegisterMultiPart with NO cellScope argument (GameWindow.cs:3264-3273, in RegisterLiveEntityCollision) → outdoor XY-grid cells only. The five landblock-static sites pass cellScope: entity.ParentCellId ?? 0u (GameWindow.cs:6176-6197, 6246-6267, 6282-6303, 6307-6328, 6506-6527) → interior statics land in exactly one cell (retail would flood them into every cell their geometry overlaps).
- The claimed compensation stack is real and all query-side: the #98 indoor-primary gate (`(primaryCellId & 0xFFFF) >= 0x0100 && !isViewer → return` before the outdoor sweep, ShadowObjectRegistry.cs:494-495), the A6.P4-slice-1 portalReachableCells outdoor-id iteration explicitly documented as the #99 door-reachability patch (ShadowObjectRegistry.cs:413-428), and the hasExitPortal topology widening in CellTransit.cs (:102,:132,:614-616). The 3×3-landblock query sweep (ShadowObjectRegistry.cs:502-539) compensates the registration-side landblock clamp for the outdoor-outdoor case.
- Port-shape anchors exist as claimed: CellTransit.FindTransitCellsSphere (CellTransit.cs:53/66/107), AddAllOutsideCells (:257/:306), CheckBuildingTransit (:353), and the outdoor-seed AddAllOutsideCells + building bridge (:626-634).
JUDGMENT: the divergence is real and not behaviorally equivalent. Retail computes cell membership ONCE at registration via a geometric portal flood and queries only the current cell's (plus flooded cells') shadow lists; acdream registers into a grid/single-cell approximation and then patches reachability at every query with a stack the project's own physics digest labels workarounds (#98 stopgap b3ce505 → introduced #99, OPEN HIGH; A6.P4 per-cell shadow architecture named as the open debt). Severity "critical" is justified: it breaks retail's per-cell shadow-list invariant and is the root of #99. Two honest caveats for the port plan: (a) decide per-object between the sphere flood and the find_bbox_cell_list bbox flood based on HAS_PHYSICS_BSP (0x10000) state — or document choosing the sphere flood for all as a deliberate simplification; (b) preserve the unloaded-neighbor portal-plane fallback branch of CEnvCell::find_transit_cells (0x0052c820) and the 10-sphere cap of the CylSphere wrapper (0x0052b9f0).
- blastRadius: #99 (outdoor-registered doors invisible to indoor-side collision — walk-through at thresholds, OPEN HIGH); the entire compensation stack (b3ce505 indoor gate, A6.P5 topology widening, portalReachableCells query expansion) exists because of this one divergence; also the residual risk class behind #97-family phantom collisions at indoor/outdoor seams.
- retailEvidence: CObjCell::find_cell_list (Ghidra 0x0052b4e0, pc:308742): outdoor seed → add_all_outside_cells; indoor seed → that one cell; then a growing-array walk calling each cell's find_transit_cells (vtable+0x80). CEnvCell::find_transit_cells (Ghidra 0x0052c820): neighbor added IFF the object's spheres intersect the neighbor's CCellStruct BSP (sphere_intersects_cell != OUTSIDE); exterior portal → add_all_outside_cells IFF a sphere straddles the portal plane (|dist| < r+ε). Outdoorindoor via CLandCell::find_transit_cells CBuildingObj::find_building_transit_cells CEnvCell::check_building_transit (Ghidra 0x00533800/0x006b5230/0x0052c5d0): interior cell added IFF other_portal_id >= 0 AND a sphere intersects its BSP. Result written per cell by add_shadows_to_cells → add_shadow_object (Ghidra 0x00514ae0/0x0052b280); a doorway door lands in BOTH the outdoor cell's and the vestibule's shadow_object_list at registration.
- acdreamEvidence: ShadowObjectRegistry.Register: cellScope single cell (ShadowObjectRegistry.cs:62-72) else outdoor 24m XY-grid rectangle clamped to one landblock (ShadowObjectRegistry.cs:77-105); RegisterMultiPart same per shape (162-186). Server-spawned doors pass no cellScope (GameWindow.cs:3264-3273) → outdoor cells only. No portal traversal or sphere-vs-cell-BSP test anywhere in registration.
- portShape: BuildShadowCellSet(m_positionCellId, shapeSpheres) that reuses the ALREADY-PORTED CellTransit machinery: indoor seed → growing-array walk with FindTransitCellsSphere (CellTransit.cs:107, minus the hasExitPortal widening, minus the membership pick); outdoor seed → AddAllOutsideCells + CheckBuildingTransit (CellTransit.cs:626-634 — the retail building bridge, already in-tree). Write the entry into every returned cell's list. Spheres = the entity's real collision shapes (per-shape CylSphere/BSP bounding spheres), mirroring retail's CylSphere-derived sphere set (Ghidra 0x0052b9f0).
### [CRITICAL] flat-object-query-not-per-cell (confirmed) — Object collision is one flat radial+portal-set query per step instead of per-cell shadow-list iteration
- correctedClaim: Claim stands as written, with one refinement to the port shape: a flat union of the FindCellSet cells' shadow lists fixes the query SET but not retail's per-cell ordering — retail interleaves env+object collision per cell (find_collisions = env BSP → building → find_obj_collisions, primary cell first via insert_into_cell, then each other cell via check_other_cells, halting on the first non-OK). A fully faithful A6.P4 port should run find_obj_collisions per cell at those two retail call sites rather than as a single phased union query.
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C), all four load-bearing functions checked:
1. CTransition::transitional_insert @ 0x0050b6f0: per attempt calls insert_into_cell(this, sphere_path.check_cell, num_attempts), and on OK_TS immediately calls check_other_cells(this, check_cell). So the per-step object query is strictly cell-driven: primary cell first, then every other overlapped cell.
2. CTransition::insert_into_cell @ 0x00509e70: loops calling `(**(code**)(param_1->_padding_ + 0x88))(this)` — vtable slot +0x88 on the cell. The BN pseudo-C at pc:271984-272030 names this slot find_collisions; the Ghidra decompile of CEnvCell::find_collisions calls the ADJACENT slot +0x8c (find_env_collisions) internally, consistent with +0x88 = find_collisions. The claim's caller/callee composition ("insert_into_cell calls check_cell->find_collisions") is accurate: transitional_insert passes check_cell as param_1.
3. CTransition::check_other_cells @ 0x0050ae50: builds the overlap set via CObjCell::find_cell_list(&this->cell_array, ...), then for each non-null cell != primary calls the SAME vtable slot +0x88 (find_collisions); COLLIDED/ADJUSTED halt, SLID clears contact_plane_valid/is_water and returns. Matches pc:272735 as claimed.
4. The find_collisions implementations: CEnvCell::find_collisions @ 0x0052c100 = vtable+0x8c (env BSP) then CObjCell::find_obj_collisions; CLandCell::find_collisions @ 0x00532d60 = env → CSortCell::find_collisions (building, @ 0x005340a0) → CObjCell::find_obj_collisions. Both concrete cell types end in find_obj_collisions as claimed.
5. CObjCell::find_obj_collisions @ 0x0052b750: iterates ONLY this->shadow_object_list (num_shadow_objects), skipping parented objects and self, calling CPhysicsObj::FindObjCollisions per entry. No distance/radius filter of any kind. The only spatial operation in the whole chain is find_cell_list's sphere-to-cell overlap (cell membership, not a radial object gather). "No spatial radius exists anywhere in the retail query path" — confirmed.
ACDREAM SIDE — every cited line checked against the actual code:
- src/AcDream.Core/Physics/TransitionTypes.cs:862-925 (TransitionalInsert): exactly ONE FindObjCollisions(engine) per attempt at :900, after FindEnvCollisions at :876. Confirmed.
- TransitionTypes.cs:855-860: the concession comment is verbatim — "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query."
- TransitionTypes.cs:2340: `float queryRadius = sphereRadius + movement.Length() + 5f;` — the +5m pad, confirmed.
- TransitionTypes.cs:2358-2359: `CellTransit.FindCellSet(...)` already computes portalReachableCells (containing-cell result discarded with `_ =`), passed into GetNearbyObjects at :2376-2382 with primaryCellId + isViewer. Confirmed.
- src/AcDream.Core/Physics/ShadowObjectRegistry.cs:430-540 (GetNearbyObjects): first unions the portal-reachable cells' shadow lists (:460-471), then the b3ce505 indoor gate at :494-495 (`if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer) return;`), then falls through to the 9-landblock radial 24m-cell grid sweep (:497-539) for outdoor primaries. Confirmed exactly as claimed, including the isViewer exemption (comment :482-493 admits "Retail's find_obj_collisions ... has NO indoor-cell gate — the gate is acdream-specific").
- #98 causal claim independently corroborated by the in-code comment at ShadowObjectRegistry.cs:473-480: "the landblock-wide cottage GfxObj was returned by the unconditional radial sweep."
NOT-HANDLED-ELSEWHERE CHECK (the conflation risk): acdream DOES have a CheckOtherCells port (A4), so I verified what it iterates — TransitionTypes.cs:1632-1769 runs ONLY environment collision per other cell (terrain ValidateWalkable :1650-1684 for outdoor ids, cell BSP via BSPQuery.FindCollisions :1728-1732 for indoor). No shadow-object iteration anywhere in it. So per-cell object collision is genuinely absent, not relocated.
BEHAVIORAL-EQUIVALENCE CHECK: not equivalent. (a) Outdoor primaries test the union of cellSet + every shadow list within queryRadius over a 24m cell grid — objects in cells the sphere never overlaps get tested (the +5m pad guarantees this); retail tests only overlapped cells' lists. (b) Indoors, the gate makes the SET roughly retail-shaped, but doors registered at outdoor cells (cellScope=0, GameWindow.cs:3139 per the comment at TransitionTypes.cs:2342-2354) are only visible via the AddAllOutsideCells straddle — the registration rule diverges from retail's register-into-every-overlapped-cell, which is exactly why #99 exists. (c) Halt ordering differs: retail halts per cell in cell_array order with the primary cell's env+obj interleaved in one find_collisions call; acdream phases env-all-cells then objects-flat, so which collision wins first can differ.
CAVEATS (noted, not verdict-changing): (1) The blast-radius attribution of #97/#100/#101 specifically to the +5m pad is plausible-but-not-independently-re-proven here; the digest lists them as the phantom-collision class and the mechanism (radial gather tests non-overlapped cells' objects) structurally produces that class. (2) The proposed port shape (flat union of FindCellSet cells' shadow lists) fixes the SET but would still not reproduce retail's per-cell halt ordering or the per-cell env/obj interleave; a fully faithful port iterates find_obj_collisions per cell inside the per-cell find_collisions sequence (primary via insert_into_cell, others via check_other_cells). The divergence as claimed — flat radial+portal-set query vs per-cell shadow-list iteration — is real, and the severity rating (critical: necessitated the b3ce505 stopgap which introduced OPEN-HIGH #99, blocks A6.P4 slice 3) is justified.
- blastRadius: #98's original cause (cellar sphere found the cottage via the radial sweep) and the reason the b3ce505 stopgap + isViewer exemption exist; the +5m pad finds objects retail would never test (phantom-collision class #97/#100/#101); blocks deleting the stopgap (A6.P4 slice 3).
- retailEvidence: insert_into_cell calls check_cell->find_collisions (pc:271991-272030); check_other_cells calls every other overlapped cell's find_collisions (vtable+0x88, pc:272735); each find_collisions ends in CObjCell::find_obj_collisions(this) which iterates ONLY this->shadow_object_list (Ghidra 0x0052b750). No spatial radius exists anywhere in the retail query path.
- acdreamEvidence: TransitionalInsert runs ONE FindObjCollisions per attempt (TransitionTypes.cs:900; concession comment at 856-859); queryRadius = sphereRadius + movement + 5f (TransitionTypes.cs:2340); GetNearbyObjects falls through to a 9-landblock radial grid sweep for outdoor primaries (ShadowObjectRegistry.cs:498-539) gated off for indoor primaries by the b3ce505 stopgap (ShadowObjectRegistry.cs:494-495).
- portShape: After registration-side flood ships: GetNearbyObjects(cellSet) = union of the shadow lists of exactly the transition's FindCellSet cells (already computed at TransitionTypes.cs:2358). Delete the radial sweep, the +5m pad, the primaryCellId gate, and the isViewer exemption (the viewer's own cell set reaches whatever its swept sphere overlaps). This is A6.P4 slices 1→3 with the corrected registration rule.
### [HIGH] building-shell-as-shadow-object (adjusted) — Building shells are landblock-wide shadow entries; retail buildings are a per-LandCell channel that indoor cells structurally cannot reach
- correctedClaim: Building shells in acdream are outdoor-grid footprint shadow entries (Register cellScope=0, ShadowObjectRegistry.cs:74-105) found by a membership-blind 3x3-landblock radial sweep (GetNearbyObjects:497-539) — reachable from any position pre-gate; retail instead stores at most ONE CBuildingObj pointer per outdoor CLandCell (CSortCell.building, acclient.h:31882, set once at the building's origin cell by init_buildings 0x0052fd80) and tests it ONLY inside CLandCell::find_collisions (0x00532d60 -> 0x005340a0 -> 0x006b5300). CORRECTION 1: the unreachability is per-CELL, not per-primary — an indoor-primary sphere straddling an exit portal DOES test the building in retail, because CTransition::check_other_cells (0x0050ae50) calls find_collisions on every cell in the sphere's cell array including outdoor CLandCells; only a fully-interior cell array (the #98 cellar case) structurally cannot reach a building. Retail additionally weakens (not skips) the shell BSP test when the path also hits interior cells (BSPTREE::find_collisions 0x0053a440, bldg_check && hits_interior_cell) — the port must keep the straddle-case building test or doorway collision regresses. CORRECTION 2: "landblock-wide" overstates registration (it is bounding-radius footprint over the owning landblock's outdoor 8x8 grid); the membership-blind reach comes from the query's radial sweep, which is what the #98 gate (ShadowObjectRegistry.cs:494-495) and the layered isViewer exemption compensate for. Both compensations dissolve under the retail shape, with one verification item: the camera probe must then be enclosed by interior cell-BSP collision (retail's find_env_collisions channel) for the isViewer exemption to be safely removable. Port shape stands: per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already keyed this way, CellTransit.cs:631); doors stay shadow objects (retail's CObjCell::find_obj_collisions 0x0052b750 iterates shadow_object_list of dynamic CPhysicsObjs — buildings are not in it).
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra decompiles (not BN pseudo-C):
1. CLandCell::find_collisions (Ghidra 0x00532d60): vcall +0x8c (= find_env_collisions, CLandCell impl at 0x00532f20 — terrain/cell structure) -> CSortCell::find_collisions -> CObjCell::find_obj_collisions. CONFIRMED as claimed.
2. CSortCell::find_collisions (Ghidra 0x005340a0): `if (this->building != NULL) return CBuildingObj::find_building_collisions(building, trans); return OK_TS;`. CONFIRMED — the building channel is a single per-LandCell pointer (CSortCell.building, acclient.h:31882; CSortCell : CObjCell at acclient.h:31880, CLandCell : CSortCell at :31886).
3. CEnvCell::find_collisions (Ghidra 0x0052c100): vcall +0x8c (= find_env_collisions, CEnvCell impl at 0x0052c130 — the cell's own BSP) -> CObjCell::find_obj_collisions only. NO building call, and CEnvCell : CObjCell (acclient.h:32072) structurally has no building field. CONFIRMED.
4. CObjCell::find_obj_collisions (Ghidra 0x0052b750): iterates this->shadow_object_list (dynamic CPhysicsObjs) only — buildings are not shadow objects in retail. CONFIRMED the channels are disjoint.
5. CBuildingObj::find_building_collisions (Ghidra 0x006b5300): sets sphere_path.bldg_check=1, runs CPhysicsPart::find_obj_collisions on the building's physics part (shell BSP), sets collided_with_environment. CONFIRMED it is an environment-style BSP test, not a shadow-entry test.
6. Registration: CLandBlock::init_buildings (Ghidra 0x0052fd80) -> LandDefs::adjust_to_outside on the building origin -> get_landcell -> CBuildingObj::add_to_cell (0x006b5550) -> CSortCell::add_building (0x00534030, first-wins). Each building registers in EXACTLY ONE CLandCell (its origin cell). Confirms the proposed "per-LandCell building reference" port shape matches retail's actual data shape.
7. NUANCE THAT FORCES THE ADJUSTMENT: CTransition::check_other_cells (Ghidra 0x0050ae50) vcalls find_collisions (vtable +0x88) on EVERY cell in the sphere's cell array. When the sphere straddles an exit portal, the array contains outdoor CLandCells, whose find_collisions DOES reach the building. So "an indoor primary can never test a building shell" is overstated — the correct structural statement is per-CELL, not per-primary. The #98 blast radius survives: deep in the cellar (no exit-portal straddle) the cell array is all CEnvCells, so retail structurally never tests the cottage shell. Supporting design evidence: BSPTREE::find_collisions (Ghidra 0x0053a440) further WEAKENS the shell test when bldg_check && hits_interior_cell != 0 (placement/ethereal inserts pass centerSolid=false) — retail deliberately mutes shell collision for spheres engaged with interior cells even on the straddle path; a faithful port must preserve the straddle-case building test or doors/doorways regress.
ACDREAM SIDE — read the cited code:
1. src/AcDream.App/Rendering/GameWindow.cs:6176-6182 (note: path is Rendering/GameWindow.cs): per-part Register(...) with cellScope: entity.ParentCellId ?? 0u. Landblock-baked building stabs have ParentCellId = null (src/AcDream.Core/World/WorldEntity.cs:69-74; WbDrawDispatcher.cs:452 "building shell (no ParentCellId)") -> cellScope=0 path. CONFIRMED.
2. ShadowObjectRegistry.cs Register cellScope==0 path (lines 74-105): footprint registration into the owning landblock's outdoor 8x8 grid cells overlapped by world bounding radius. "Landblock-wide" is shorthand — registration is footprint-over-outdoor-grid; but the QUERY (GetNearbyObjects lines 497-539) radially sweeps the 3x3 landblock neighborhood, so pre-gate the shell is reachable from ANY position within queryRadius regardless of cell membership. Functional claim CONFIRMED.
3. Both compensations are inline-documented at ShadowObjectRegistry.cs:473-495: the #98 indoor gate `if ((primaryCellId & 0xFFFF) >= 0x0100 && !isViewer) return;` (494-495, comment 473-480 names #98/cellar Z-cap explicitly) and the Phase U isViewer exemption (comment 482-493). CONFIRMED.
4. Production call site (not just tests): Transition.FindObjCollisions at src/AcDream.Core/Physics/TransitionTypes.cs:2376-2382 passes primaryCellId: sp.CheckCellId, isViewer: oi.IsViewer, plus portalReachableCells from CellTransit.FindCellSet (:2358) — the gate is live and load-bearing. Also note the portal-reachable pass (ShadowObjectRegistry.cs:460-471) runs BEFORE the gate, so indoor-straddle spheres already reach outdoor-registered entries via A6.P5's AddAllOutsideCells (CellTransit.cs:614-618) — acdream's partial emulation of retail's straddle behavior.
5. cache.GetBuilding(landcellId) exists and is used for transit at src/AcDream.Core/Physics/CellTransit.cs:631 — port-shape claim CONFIRMED.
JUDGMENT: the structural divergence is REAL, not behaviorally-equivalent-elsewhere — acdream itself documents the two compensations as patches over exactly this shape, and the physics digest classifies the b3ce505 gate as a workaround. The only caveat on the blast-radius claim: whether the isViewer exemption fully dissolves under the retail port depends on acdream's interior cell-BSP collision actually enclosing the camera probe the way retail's CEnvCell find_env_collisions does — plausible (retail bounds the viewer with the EnvCell's own BSP, same channel) but not proven here; treat as a port-plan verification item, not a refutation.
- blastRadius: #98 cellar Z-cap root cause (cottage found from the cellar); keeps the b3ce505 gate load-bearing; the camera isViewer exemption (Phase U) is a second compensation layered on the first — both dissolve under the retail shape.
- retailEvidence: Building collision fires only from CLandCell::find_collisions → CSortCell::find_collisions → CBuildingObj::find_building_collisions (Ghidra 0x00532d60, 0x005340a0); CEnvCell::find_collisions (Ghidra 0x0052c100) has env + shadow objects only — no building channel. An indoor primary can never test a building shell.
- acdreamEvidence: Landblock-baked building GfxObjs register as shadow entries with cellScope=0 → outdoor-grid footprint (GameWindow.cs:6176-6182 family; ShadowObjectRegistry.cs:77-105), then are found (pre-gate) by any radial query within reach; ShadowObjectRegistry.cs:484-495 documents both compensations inline.
- portShape: Move building-shell collision out of ShadowObjectRegistry into a per-LandCell building reference consulted only in the outdoor-cell branch of the per-cell query (cache.GetBuilding already exists — CellTransit.cs:631 uses it for transit). Doors stay shadow objects (they are dynamic CPhysicsObjs in retail too).
### [HIGH] check-other-cells-env-only (confirmed) — CheckOtherCells iterates other cells for environment collision only; retail runs env AND shadow objects per other cell
- correctedClaim: Claim stands as stated, with three precision additions: (1) retail's per-other-cell find_collisions on land cells also interposes the building check (CSortCell::find_collisions → CBuildingObj::find_building_collisions @0x005340a0) between env and objects; (2) retail also runs the PRIMARY cell's objects inside insert_into_cell's own inner retry loop (acdream's retry wraps env+obj at the outer attempt level only); (3) acdream advances the carried cell BEFORE its flat object pass while retail advances it AFTER all per-cell object passes. acdream span is TransitionTypes.cs:1632-1769 (not :1750).
- verifier notes: RETAIL SIDE — re-derived entirely from Ghidra (not BN pseudo-C):
1. CTransition::check_other_cells @0x0050ae50 (Ghidra decompile): builds the cell array via CObjCell::find_cell_list, then for every cell != the check cell calls `(**(code **)(pCVar1->_padding_ + 0x88))(this)`. Switch on result: COLLIDED_TS/ADJUSTED_TS return; SLID_TS clears collision_info.contact_plane_valid AND contact_plane_is_water then returns — exactly as claimed (the claim's pc:272752-272760 matches; Ghidra confirms the BN listing did not invent this branch). After the loop it sets check_cell = the new containing cell and adjust_check_pos (cell advance AFTER all per-cell collision).
2. The vtable+0x88 slot identity was the refutation-critical claim, and it is PROVEN, not inferred: CEnvCell::CEnvCell @0x0052c240 disassembly contains `MOV dword ptr [ESI],0x7c8c98` (instruction at 0x0052c286) — primary CEnvCell vftable base = 0x007c8c98. Ghidra DATA xref to CEnvCell::find_collisions (0x0052c100) is from 0x007c8d20 = base + 0x88. DATA xref to CEnvCell::find_env_collisions (0x0052c130) is from 0x007c8d24 = base + 0x8c. Same adjacency in CLandCell's vtable (find_collisions 0x00532d60 ref'd from 0x007c9398; find_env_collisions 0x00532f20 from 0x007c939c). So vtable+0x88 IS find_collisions; +0x8c is find_env_collisions (which is what find_collisions itself calls internally — no recursion ambiguity).
3. What per-cell find_collisions does (Ghidra decompiles): CEnvCell::find_collisions @0x0052c100 = vtable+0x8c (find_env_collisions) then if OK_TS → CObjCell::find_obj_collisions. CLandCell::find_collisions @0x00532d60 = env, then CSortCell::find_collisions @0x005340a0 (= CBuildingObj::find_building_collisions when the sort cell has a building), then CObjCell::find_obj_collisions. CObjCell::find_obj_collisions @0x0052b750 iterates `this->shadow_object_list` (the PER-CELL shadow-object list), calling CPhysicsObj::FindObjCollisions per entry, skipping parented objects and self, gated off for INITIAL_PLACEMENT_INSERT. So retail's per-other-cell query is env AND building AND per-cell shadow objects — the claim said env+objects; the building interposition for land cells is an additional (claim-strengthening) detail.
4. Ordering context (Ghidra): CTransition::transitional_insert @0x0050b6f0 per attempt calls insert_into_cell(check_cell, num_attempts) — which @0x00509e70 runs the PRIMARY cell's full vtable+0x88 find_collisions (env→[bldg]→objects) in its own inner retry loop — and only on OK_TS calls check_other_cells. So retail interleaves object collision per cell: primary-cell objects are tested before any other cell's env, and each other cell tests its own objects immediately after its env.
ACDREAM SIDE — read at the cited locations:
5. Transition.CheckOtherCells, src/AcDream.Core/Physics/TransitionTypes.cs:1632-1769 (claim said :1632-1750 — trivially off, same function): per other cell, outdoor ids (low16 < 0x100) engine.SampleTerrainWalkable + ValidateWalkable (:1650-1684); indoor ids BSPQuery.FindCollisions on the cell's CellStruct BSP (:1687-1732). NO shadow-object / registry iteration anywhere in the loop. Confirmed env-only.
6. The flat object pass: TransitionTypes.cs:900 (`var objState = FindObjCollisions(engine);` inside TransitionalInsert Phase 2) is the sole FindObjCollisions call site in the insert path (grep over src/AcDream.Core confirms; :2307 is the definition, which queries the landblock-wide ShadowObjectRegistry). The stale comment at TransitionTypes.cs:856-859 explicitly documents the simplification: "we don't have CellArray/CheckOtherCells iteration because our FindObjCollisions (via ShadowObjectRegistry) is already a flat per-landblock query" written pre-A4; A4 later gave the ENV half per-cell treatment (CheckOtherCells) but the object half never got it.
7. Resolution-order divergence confirmed real: acdream per attempt = primary env (FindEnvCollisions :1954, indoor BSP :2056 / terrain :2120) CheckOtherCells env-only (:2185) carried-cell advance (:2192-2193) flat object pass (:900). Retail per attempt = primary [envbldgobj] (with inner retry) per other cell [envbldgobj] cell advance. Two extra ordering deltas beyond the claim: (a) retail runs primary-cell objects inside insert_into_cell's inner retry loop, acdream's retry wraps both phases at the outer level; (b) acdream advances the carried cell BEFORE its object pass, retail advances AFTER all per-cell object passes.
JUDGMENT: both sides check out from primary sources; the divergence is real and not behaviorally-equivalent-elsewhere. The "masked today" framing is accurate the flat landblock-wide registry query is a superset of the per-cell lists, so objects are not LOST today; the live divergence is the interleaving/ordering (a plausible contributor to threshold deltas in the #99/#108/#109 doorway family, though not proven causal for any specific one) plus the structural hole that opens the moment the A6.P4 per-cell shadow port lands. The proposed port shape (fold per-cell shadow iteration into CheckOtherCells AND the primary-cell insert, retiring the flat pass) matches retail's actual call graph. Severity "high" is fair: no standalone user-visible artifact today, but it gates the A6.P4 architecture that closes #99.
- blastRadius: Masked today by the flat object query; becomes a correctness hole the moment the per-cell port lands (straddle coverage at doorways would be lost). Also a resolution-order divergence: retail interleaves obj collision per cell, acdream resolves all env then all objects contributes to threshold-behavior deltas in the #99/#108/#109 doorway family.
- retailEvidence: check_other_cells calls each other cell's vtable+0x88 find_collisions (pc:272735), which is find_env_collisions THEN find_obj_collisions per cell (Ghidra 0x0052c100 / 0x00532d60); SLID clears contact-plane fields and returns (pc:272752-272760).
- acdreamEvidence: Transition.CheckOtherCells (TransitionTypes.cs:1632-1750) runs terrain ValidateWalkable (outdoor ids) or cell-BSP FindCollisions (indoor ids) only; no shadow-object iteration per cell. The flat FindObjCollisions at TransitionTypes.cs:900 is the sole object pass.
- portShape: Fold the per-cell shadow-list iteration into the same loop CheckOtherCells already walks (and into the primary-cell insert), making each cell's query env-then-objects like retail's find_collisions; retire the separate Phase-2 flat object pass in TransitionalInsert.
### [MEDIUM] a6p5-topology-widening (adjusted) — hasExitPortal topology widening adds outdoor cells to the collision set wider than retail's straddle gate
- correctedClaim: CONFIRMED divergence, CORRECTED port shape. Divergence (as claimed, medium severity): acdream widens the COLLISION cell set by topology any sphere-overlapped indoor cell possessing an exterior (0xFFFF) portal triggers AddAllOutsideCells once per walk (CellTransit.cs:130-132, 614-618) whereas retail adds outside cells only when a path sphere straddles the exterior portal plane, |dist| < radius + F_EPSILON (Ghidra 0x0052c820; caller 0x0052b4e0 has no topology branch either). Membership is unaffected (outdoorPickAllowed gate, CellTransit.cs:571/603/675). It is a deliberate, self-documented #99 stopgap keeping outdoor-registered (cellScope=0) doors findable from indoor cells via ShadowObjectRegistry.GetNearbyObjects(portalReachableCells). CORRECTED port shape: after A6.P4 registration-side flood places doors into indoor cells' shadow lists, do NOT delete lines 614-618 that would remove acdream's only indoor-path outside-add and break retail-faithful exit demotion (straddle fires on every real building exit; without outdoor candidates in the array the pick keeps the player indoor-classified, lines 675/693/732 + comment 705-709). Instead RE-GATE the AddAllOutsideCells call from hasExitPortal to the already-implemented retail straddle flag (exitOutsideStraddle, CellTransit.cs:160-175/597), then delete the hasExitPortal plumbing (out-param and line 130-132 assignment). Once-per-walk semantics (outdoorAdded) are already retail-faithful retail's CELLARRAY.added_outside guard at 0x00533630 does the same.
- verifier notes: RETAIL re-derived from Ghidra decompiles (not BN pseudo-C). (1) CEnvCell::find_transit_cells @ 0x0052c820: in the exterior-portal branch (other_cell_id == 0xffffffff) a local flag (bVar7) is set iff, for some path sphere, -(F_EPSILON + radius) < dist < +(F_EPSILON + radius) against the portal plane the straddle test exactly as claimed; CLandCell::add_all_outside_cells is called after the portal loop ONLY when that flag fired ('if (bVar7) CLandCell::add_all_outside_cells(...)'). No topology-only branch; no portal_side/exact_match test in this branch (portal_side appears only in the unloaded-interior-portal branch). (2) Caller CObjCell::find_cell_list @ 0x0052b4e0: indoor seed adds only the current cell then vtable-dispatches find_transit_cells over the growing CELLARRAY; add_all_outside_cells is called directly only for outdoor seeds ((id & 0xffff) < 0x100) so retail has no topology widening at the caller level either. (3) add_all_outside_cells @ 0x00533630 guards on CELLARRAY.added_outside (once per walk) acdream's outdoorAdded flag at CellTransit.cs:588/617 is retail-faithful on that sub-point. ACDREAM verified: hasExitPortal set purely on portal.OtherCellId == 0xFFFF topology (CellTransit.cs:130-132; doc 102-106 self-describes 'NOT retail'); the widening fires once per indoor walk on hasExitPortal regardless of straddle (CellTransit.cs:614-618, comment 608-613: 'by TOPOLOGY wider than retail'); the membership PICK is protected by outdoorPickAllowed (CellTransit.cs:571, 603, consumed at 675) so this diverges only in the collision cell SET. Blast radius confirmed real: the widened set feeds ShadowObjectRegistry.GetNearbyObjects which iterates portalReachableCells unconditionally (ShadowObjectRegistry.cs:460-471), and cellScope=0 entities (server-spawned doors per the comment at ShadowObjectRegistry.cs:422-423) are keyed under outdoor landcell ids (Register, ShadowObjectRegistry.cs:77-105); Transition.CheckOtherCells also consumes outdoor ids from the set (TransitionTypes.cs:1650-1684, limited to the under-foot terrain column). Strictly wider than retail: a sphere anywhere inside a cell bearing an exterior portal (doors AND outside-facing window portals are 0xFFFF) gets outdoor cells in its collision set; retail requires plane straddle. One precision note: the widening triggers from cells IN the sphere-overlapped candidate set that possess an exterior portal (the occupied cell or an overlapped neighbour) deep-interior cells without exterior portals never trigger it; the claim's 'ANY indoor cell that merely possesses an exit portal' is correct read that way. THE ADJUSTMENT the claimed port shape is materially wrong: CellTransit.cs:614-618 is the ONLY AddAllOutsideCells call on the indoor path; exitOutsideStraddle currently gates only the pick (line 603), there is no separate straddle-gated set-add. Deleting lines 614-618 outright (as the port shape instructs) would also delete the retail straddle-fired outside flood: on a real building exit the straddle fires but no outdoor candidate would enter the array, outdoorResult could never set (line 675 iterates candidates), and the pick would fall through to keep-curr (line 732) the player would stay indoor-classified after walking out, a membership regression the code's own comment (lines 705-709) documents depending on 'outside cells enter the candidate array the normal outdoorResult path demotes there, retail-faithfully'.
- blastRadius: Keeps #99 partially papered over today (named question 5): outdoor-registered doors stay findable from ANY indoor cell that merely possesses an exit portal, not just when a sphere straddles it. Over-wide set = extra false-positive collision candidates from deep interior cells near exits.
- retailEvidence: CEnvCell::find_transit_cells adds outside cells ONLY when a path sphere straddles an exterior portal plane |dist| < radius + ε (Ghidra 0x0052c820; live-binary verified 2026-06-10 per CellTransit.cs:134-145 comment). No topology-only branch exists.
- acdreamEvidence: hasExitPortal output (CellTransit.cs:102-106, set at 130-132) drives AddAllOutsideCells once per indoor walk regardless of straddle (CellTransit.cs:614-618); self-documented as a non-retail #99 stopgap pending A6.P4. The membership PICK already ignores it (outdoorPickAllowed gate, CellTransit.cs:571/603).
- portShape: Once registration-side flood places doors into indoor cells' lists (divergence 1), delete hasExitPortal and the lines 614-618 widening; the collision cell set reverts to the pure straddle-gated retail flood. This is the retail answer to named question 5: the door is found in the indoor cell's OWN list, so the indoor query never needs outdoor cells it doesn't overlap.
### [MEDIUM] single-landblock-grid-clamp (confirmed) — Registration grid clamps to the entity's own landblock; retail's add_all_outside_cells crosses block borders
- correctedClaim: Confirmed as claimed, with one strengthening refinement: the divergence is not strictly invisible today. The 9-landblock query sweep compensates only on the outdoor radial path; the #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) skips that sweep, so an indoor sphere reaching outdoor cells through portalReachableCells (exact cell-id lookups, block-crossing per CellTransit.AddAllOutsideCells) can ALREADY miss an object whose footprint crosses a landblock seam but is registered only under its own block's prefix. The future A6.P4 slice-3 deletion of the radial sweep widens this from "doorway-at-a-seam" to all block-seam footprints.
- verifier notes: RETAIL SIDE re-derived entirely from Ghidra decompiles (not BN pseudo-C), and it checks out end-to-end:
1. Registration path uses the same block-agnostic cell-set machinery as transit. CPhysicsObj::calc_cross_cells (Ghidra 0x00515230) builds a CELLARRAY via CObjCell::find_cell_list(&m_position, numSpheres, spheres, &cell_array, NULL) (or find_bbox_cell_list for state&0x10000 objects), then calls remove_shadows_from_cells + add_shadows_to_cells(this, &cell_array). add_shadows_to_cells (Ghidra 0x00514ae0) iterates the CELLARRAY and calls CObjCell::add_shadow_object per cell so retail's shadow registration set IS the find_cell_list output, verbatim.
2. find_cell_list (Ghidra 0x0052b4e0, the citation in the claim verified correct) calls CLandCell::add_all_outside_cells(position, numSpheres, spheres, cellArray) whenever the primary cell is outdoor ((objcell_id & 0xffff) < 0x100).
3. add_all_outside_cells sphere variant (Ghidra 0x00533630): per sphere, calls LandDefs::adjust_to_outside(&cellId, &center). adjust_to_outside (Ghidra 0x005a9bc0) re-homes the point across landblock borders: get_outside_lcoord lcoord_to_gid replaces the cell id with whatever block the point actually falls in, then folds the local origin back into [0, block_length) via floor(x/block_length) subtraction. Then gid_to_lcoord yields GLOBAL landscape coordinates and add_outside_cell + check_add_cell_boundary add cells by global lcoord.
4. add_outside_cell (Ghidra 0x00532ec0): bounds-checks lcoords to [0, 0x7F8) (255×8 cells, whole map) and composes gid = (((x&~7)<<5)|(y>>3))<<16 | ((x&7)*8+(y&7)+1) the landblock prefix is RE-DERIVED from the global lcoord. check_add_cell_boundary (Ghidra 0x00533260) adds lx±1/ly±1 neighbors in global lcoords when the sphere center is within radius of a 24m cell edge crossing a multiple-of-8 lcoord lands in the adjacent landblock with the adjacent block's prefix. Block-agnostic, confirmed.
5. The bbox/parts variant (Ghidra 0x00533360, used via find_bbox_cell_list for state&0x10000 objects) computes a global-lcoord rectangle over all parts' bounding boxes and calls add_cell_block(xmin,ymin,xmax,ymax) also block-agnostic. Both registration shapes cross block borders.
ACDREAM SIDE cited lines verified exact:
- ShadowObjectRegistry.Register clamps minCx/maxCx/minCy/maxCy to [0,7] (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:80-83) and composes cell ids under the single lbPrefix = landblockId & 0xFFFF0000 (:87, :93). RegisterMultiPart does the same per shape (:173-176, prefix at :140, compose at :182). Entries never land under a neighbor block's prefix (the cellScope path :62-72 is exact-cell and irrelevant to the outdoor grid).
- GetNearbyObjects compensates at query time with the 9-landblock sweep (:502-539), computing per-neighbor local coords and clamped ranges.
- Production registration sites pass the entity's own landblock (e.g. GameWindow.cs:6176-6182 passes origin.X, origin.Y, lb.LandblockId with worldRadius = local bounding-sphere radius × scale), so a building part near a seam genuinely loses its neighbor-block footprint cells.
- The transit-side port CellTransit.AddAllOutsideCells (src/AcDream.Core/Physics/CellTransit.cs:257-331) is a faithful block-crossing port of the same retail routine (AdjustToOutside + GidToLcoord + LcoordToGid, explicit "NO same-block filter" note at :324-328; boundary-neighbor signs at :284-297 match the Ghidra decompile of check_add_cell_boundary exactly: point > cellLenr → +1, point < r 1). So the proposed port shape (have registration reuse this) is sound.
ONE REFINEMENT (strengthens, doesn't weaken): the blast-radius statement "invisible today only because the query side sweeps 9 landblocks" is slightly understated. The #98 indoor-primary gate (ShadowObjectRegistry.cs:494-495) RETURNS before the 9-block sweep when the querying sphere's primary cell is indoor (and not viewer) production call site TransitionTypes.cs:2376-2382 passes primaryCellId = sp.CheckCellId. On that path, outdoor-registered shadows are reachable ONLY via exact cell-id lookups from portalReachableCells (:460-471), whose outdoor ids come from the block-crossing CellTransit.AddAllOutsideCells. If a building/door footprint straddles a landblock seam and the indoor sphere's exit-portal expansion yields a NEIGHBOR-block outdoor cell, the clamped registration means that lookup misses the object TODAY no sweep compensates. Geometrically narrow (doorway at a block seam), but the divergence is already live on the indooroutdoor portal query path, not purely a future A6.P4-slice-3 exposure. Severity "medium" remains fair.
Minor caveat on the port shape: "BuildShadowCellSet" is the planned A6.P4 component named in docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md it does not exist in src/ yet. The port shape should be read as "the A6.P4 registration cell-set builder must use CellTransit.AddAllOutsideCells (block-crossing) instead of inheriting the registry's private clamped grid math" which this verification supports. Also note for the porter: RemoveLandblock (ShadowObjectRegistry.cs:337-361) removes cells by landblock prefix; once registration crosses blocks, an entity's entries can live under multiple prefixes and landblock unload must not strand or half-remove them (retail handles this via remove_shadows_from_cells per object, Ghidra 0x00515230 caller).
- blastRadius: Invisible today only because the query side sweeps 9 landblocks; the moment the radial sweep is deleted (A6.P4 slice 3), an object whose footprint crosses a landblock border silently vanishes from neighbor-block cells missing collisions at block seams.
- retailEvidence: CLandCell::add_all_outside_cells operates on lcoords and adds whatever outdoor cells the spheres overlap, block-agnostic (called from find_cell_list Ghidra 0x0052b4e0; same routine the transit path uses the 2026-05-25 AddAllOutsideCells coord fix in acdream's TRANSIT port already handles cross-block).
- acdreamEvidence: Register/RegisterMultiPart clamp minCx/maxCx/minCy/maxCy to 0..7 under a single lbPrefix (ShadowObjectRegistry.cs:80-83, 173-176) entries never land in a neighboring landblock's cells. The 9-landblock loop in GetNearbyObjects (ShadowObjectRegistry.cs:502-539) compensates at query time.
- portShape: BuildShadowCellSet's outdoor branch should call the existing CellTransit.AddAllOutsideCells (block-crossing, already fixed) instead of the registry's private clamped grid; delete the private grid math.
### [LOW] movement-reregistration-source (confirmed) — Moved entities re-register from a fresh XY grid; retail reuses the transition's own cell array every tick
- verifier notes: RETAIL SIDE re-derived from Ghidra (not BN), all three load-bearing pieces check out:
1. SetPositionInternal tail (Ghidra decompile 0x00515330): after handle_all_collisions, the function ends with exactly the claimed gate structure: `if (this->cell != 0) { if ((this->state & 0x10000) != 0) { calc_cross_cells(this); return 1; } if ((param_1->cell_array).num_cells != 0) { remove_shadows_from_cells(this); add_shadows_to_cells(this, &param_1->cell_array); } }`. The BN pseudo-C at pc:283536-283545 (addresses 0051550b-0051554c) matches the Ghidra decompile line-for-line no invented branch here (BN's `num_cells > 0` vs Ghidra's `!= 0` is equivalent for an unsigned field). The 0x10000 flag is HAS_PHYSICS_BSP_PS per acclient.h:2833, so "BSP-bearing objects recompute via calc_cross_cells, everything else reuses the transition's cell_array" is accurate.
2. The "check_other_cells already built this step" attribution is accurate, which I specifically suspected of being a caller/callee conflation: the Ghidra decompile of CTransition::check_other_cells (0x0050ae50) BUILDS the array before consuming it its first real act is `CObjCell::find_cell_list(&this->cell_array, &local_4c, &this->sphere_path)`, then it iterates `this->cell_array.cells[i].cell`. So the CELLARRAY that SetPositionInternal hands to add_shadows_to_cells is the portal-aware set populated during this transition step.
3. add_shadows_to_cells (Ghidra 0x00514ae0) does what the divergence implies: it sizes this->shadow_objects to cell_array.num_cells, stamps each CShadowObj with `cell_array.cells[i].cell_id`, and registers each into the corresponding CObjCell via CObjCell::add_shadow_object (plus CPartArray::AddPartsShadow), recursing into children. The targets are CObjCell pointers from the transition — indoor CEnvCells included — NOT an XY ground-grid sweep. (Nuance: a `state & 0x1000` particle-emitter branch routes to add_particle_shadow_to_cell instead; irrelevant for movers.)
4. Cadence claim ("retail per successful transition step") verified via xrefs: SetPositionInternal(CTransition*) is called from UpdateObjectInternal (call site 0x00515914 — the per-tick movement path) and from SetPosition (call site 0x0051614b — server-driven position sets), so both retail paths funnel shadow re-registration through the transition's cell_array.
ACDREAM SIDE — confirmed at the cited lines plus production call sites:
5. ShadowObjectRegistry.UpdatePosition (src/AcDream.Core/Physics/ShadowObjectRegistry.cs:219-278) re-runs RegisterMultiPart (line 245) for cached multi-part entities or Register (line 274) for the single-shape path. BOTH calls leave cellScope at its default 0u, so placement always goes through the outdoor 24m XY-grid loops (Register lines 77-103; RegisterMultiPart lines 169-186). The method signature takes no cell id and no cell set — the landblockId parameter is masked to the 0xFFFF0000 prefix for grid placement only. No transition cell set is consulted anywhere in the file.
6. Production call site: exactly one — GameWindow.cs:4492, on inbound server position updates for remote entities (`update.Guid != _playerServerGuid`), passing only (entityId, worldPos, rot, origin, p.LandblockId). The spawn-time registration for server-spawned entities (GameWindow.cs:3264 RegisterMultiPart) likewise passes no cellScope. So even an entity the server reports inside an EnvCell gets its shadow re-registered into outdoor landcells on every move — consistent with the known #99 family (doors/NPCs invisible to fully-indoor queries under the #98 indoor-primary gate at ShadowObjectRegistry.cs:494).
7. Real divergence, not behavioral equivalence: retail's re-registration target set is the portal-aware transition CELLARRAY (can contain indoor cells); acdream's is the outdoor XY grid, unconditionally. The blast-radius framing is honest and correct — cadence is comparable today, but after the per-cell shadow port (divergence-1 / A6.P4), UpdatePosition's XY-grid source would clobber correct per-cell registration on an entity's first move. Severity "low" is defensible as a port-ordering footnote (its present-day visible symptom is already accounted under #99).
One refinement worth carrying into the port shape (not a correction): retail only re-registers when the transition's cell_array is NON-EMPTY — `if (num_cells != 0)` — i.e., an empty array leaves the previous shadows in place rather than recomputing or clearing. The ported UpdatePosition should preserve that keep-when-empty behavior, and should keep the HAS_PHYSICS_BSP_PS (0x10000) → calc_cross_cells split for BSP-bearing objects.
- blastRadius: Cadence is comparable (acdream re-registers per server UpdatePosition; retail per successful transition step), but after the per-cell port the SOURCE matters: re-registering via the XY grid would undo divergence-1's fix for every moving door/NPC on its first move.
- retailEvidence: SetPositionInternal(CTransition*) tail (Ghidra 0x00515330; pc:283536-283545): non-BSP objects re-register via remove_shadows_from_cells + add_shadows_to_cells(&transit->cell_array) — the array check_other_cells already built this step; HAS_PHYSICS_BSP objects recompute via calc_cross_cells.
- acdreamEvidence: ShadowObjectRegistry.UpdatePosition (ShadowObjectRegistry.cs:219-278) re-runs Register/RegisterMultiPart → XY grid placement; no transition cell set is consulted.
- portShape: UpdatePosition takes the mover's current cell id (and ideally its transition cell set when one exists) and routes through BuildShadowCellSet, mirroring SetPositionInternal's reuse; remote entities without a local transition just run the flood from their reported cell.
## OPEN QUESTIONS
- find_bbox_cell_list's leaf (CPartArray::calc_cross_cells_static, called from Ghidra 0x00510fc0) was not decompiled — the exact bbox-driven cell rule for HAS_PHYSICS_BSP_PS statics (dungeon furniture class) is unverified; matters for which registration branch acdream's BSP-bearing statics should take.
- Unloaded-cell shadows: add_shadows_to_cells (Ghidra 0x00514ae0) stores cell=null for CELLARRAY entries whose cell pointer was null (unloaded neighbor added by id in find_transit_cells); what re-attaches those shadows when the cell later loads is inside CObjectMaint::InitObjCell (called from init_objects, Ghidra 0x0052b420), which was not decompiled. This is spec §7 Q3 (streaming order) — still open, and the acdream port needs an equivalent (re-run BuildShadowCellSet for objects homed in / overlapping a newly streamed cell).
- Registration spheres: the CylSphere overload (Ghidra 0x0052b9f0) builds each flood sphere at the cylinder's low_pt with the cyl radius — i.e. the BASE of the cylinder, not its center, and ignoring height. Verified in Ghidra but surprising (tall objects' flood reach is their base only); worth a second look (or a live-binary spot check) before porting verbatim.
- INITIAL_PLACEMENT_INSERT parity: retail's find_obj_collisions skips the whole shadow loop for initial-placement inserts (Ghidra 0x0052b750); whether acdream's placement path has an equivalent skip was not checked in this area pass.
- Is the local player itself registered in acdream's ShadowObjectRegistry (the SelfEntityId skip at TransitionTypes.cs:2398 implies live entities are)? Retail registers the player like any CPhysicsObj and relies on the self-skip in find_obj_collisions; parity of WHO is in the lists (not just how they're queried) wasn't fully audited.
- check_other_cells halt semantics: the BN pseudo-C shows SLID (case 4) clearing contact-plane fields and RETURNING (pc:272752-272760), while acdream's ApplyOtherCellResult path was ported from the same lines — given this function family's invented-branch history, the exact SLID-return-vs-continue behavior deserves a one-shot Ghidra confirm of 0x0050ae50 before the per-cell port hardens it (the Ghidra server decompiled neighbors of this function fine, but I did not pull this exact one).