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>
68 KiB
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:
- 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).
- 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.
- 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.
- 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:
-
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.
-
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". -
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.
-
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. -
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 → returnbefore 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 (
b3ce505indoor 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+ε). Outdoor→indoor 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:
- 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.
- 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. - 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.
- 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.
- 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
b3ce505indoor 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
b3ce505stopgap + 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
b3ce505stopgap (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):
- 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.
- 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). - 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.
- 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.
- 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.
- 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.
- 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:
- 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.
- 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.
- 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. - 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.
- 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
b3ce505gate 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):
-
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). -
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). -
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. -
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:
-
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.
-
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. -
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 [env→bldg→obj] (with inner retry) → per other cell [env→bldg→obj] → 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:
- 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.
- 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).
- add_all_outside_cells sphere variant (Ghidra 0x00533630): per sphere, calls LandDefs::adjust_to_outside(&cellId, ¢er). 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.
- 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.
- 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 > cellLen−r → +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 indoor→outdoor 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:
-
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, ¶m_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'snum_cells > 0vs Ghidra's!= 0is 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. -
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 iteratesthis->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. -
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: astate & 0x1000particle-emitter branch routes to add_particle_shadow_to_cell instead; irrelevant for movers.) -
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:
-
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.
-
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). -
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).