acdream/docs/research/2026-06-03-cell-membership-ordered-cellarray-pseudocode.md
Erik b44dd147bc feat(physics): Stage 1 — CellArray ordered/deduped cell collection (retail CELLARRAY)
Ports retail CELLARRAY::add_cell (acclient_2013_pseudo_c.txt:701036): ordered list,
dedup by cell_id, append at end. The order is load-bearing for the verbatim
find_cell_list current-cell-first interior-wins pick (next commits) that fixes the
R1 cottage membership flap. Implements ICollection<uint> (helper-facing) +
IReadOnlyCollection<uint> (consumer-facing). 5 unit tests.

Also lands the membership-port pseudocode (workflow step 3) + the Stage-1 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:54:45 +02:00

14 KiB

Pseudocode — retail cell-membership (ordered CELLARRAY pick) → acdream port — 2026-06-03

Mandatory workflow step 3 (WRITE PSEUDOCODE) for the Stage-1 membership port (the R1 cottage "flap"). Inputs: the canonical handoff 2026-06-02-membership-verbatim-port-handoff.md §4.4/§4.5; the decomp read this session (addresses below). This doc translates the verbatim decomp to readable pseudocode BEFORE porting, to catch misreads (the frame-swap-bug lesson).


0. The finding that grounds the whole port (decomp wins over framing)

I read the persistence chain verbatim. Retail membership stability is emergent from an ordered, current-first pick over a deduped CELLARRAY — there is NO separate "portal-plane-crossing detector" that gates state mutation. The handoff's §4.4 conceptual framing ("mutate ONLY at a portal crossing, NOT re-derived per tick") is the effect; the mechanism (handoff §4.5, and verified here) is the ordered pick

  • the carried-forward seed + multi-valued collision. find_cell_list rebuilds the array every call (num_cells = 0, pc:308747) and re-picks every transition step — exactly like acdream. The ONLY divergence is acdream's unordered HashSet pick vs retail's ordered CELLARRAY (current at index 0, interior-wins-break).

⇒ The faithful Stage-1 fix is surgical: an ordered CellArray + the verbatim pick. acdream already has the persistent-state half (CellId/CellGraph.CurrCell ← swept sp.CurCellId, committed by ValidateTransition, applied by UpdateCellId = the SetPositionInternal/change_cell equivalent). No new state machine is needed.

Decomp anchors (verified this session, file docs/research/named-retail/acclient_2013_pseudo_c.txt)

Function addr pc role
CELLARRAY::add_cell 0x6b4ff0 701036 ordered append, dedup by cell_id (search→return-if-present)
CObjCell::find_cell_list (full) 0x52b4e0 308742 rebuild array; current@0; expand; ordered interior-wins pick
CObjCell::find_cell_list (sphere-path 3-arg) 0x52b960 309085 seeds from sphere_path.check_pos.objcell_id + global_sphere
CEnvCell::find_transit_cells (sphere) 0x52c820 309968 append portal neighbours; exit-portal flag → add_all_outside_cells
CTransition::check_other_cells 0x50ae50 272717 build array; collide ALL other cells; check_cell = var_4c
CTransition::validate_transition 0x50aa70 272547 on accepted move: curr_cell = check_cell
CEnvCell::find_env_collisions 0x52c130 309573 primary BSP vs seed — NO pre-pick
CPhysicsObj::SetPositionInternal(CTransition*) 0x515330 283399 change_cell iff this->cell != sphere_path.curr_cell
CPhysicsObj::change_cell 0x513390 281192 remove_object(old)/add_object(new) — pure registry move

1. CELLARRAY (the ordered, deduped collection) — add_cell @ 701036

struct CELLARRAY:
    cells: ordered list of {cell_id, cell_ptr}     # insertion order preserved
    num_cells, added_outside

add_cell(arr, id, cellPtr):
    for existing in arr.cells:                      # linear dedup by id
        if existing.cell_id == id: return           # already present → no-op
    arr.cells.append({id, cellPtr})                 # else append at the END

acdream model: List<uint> _order (order) + HashSet<uint> _seen (O(1) dedup). Add(id) appends iff _seen.Add(id). Ordered enumeration; Contains; Count. (retail also stores the CObjCell*; acdream re-resolves via cache.GetCellStruct(id) at pick time, so the id-only list suffices.)


2. find_cell_list (the build + ordered pick) @ 308742

find_cell_list(pos, num_sphere, global_sphere[], arr, out *result, sphere_path):
    arr.num_cells = 0; arr.added_outside = 0            # REBUILD every call
    objcell_id = pos.objcell_id                         # the CURRENT/seed cell
    cur = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id)
                                : CLandCell::GetVisible(objcell_id)

    if objcell_id >= 0x100:                             # interior seed
        sphere_path.hits_interior_cell = 1
        add_cell(arr, objcell_id, cur)                  # CURRENT CELL AT INDEX 0   (308766)
    else:                                               # outdoor seed
        CLandCell::add_all_outside_cells(pos, num_sphere, global_sphere, arr)

    if cur != null and num_sphere != 0:
        # EXPAND — single forward walk over a GROWING array (find_transit_cells appends)
        for i in 0 .. arr.num_cells-1:                  # condition re-evaluated; array grows (308775)
            if arr.cells[i].cell != null:
                arr.cells[i].cell.find_transit_cells(pos, num_sphere, global_sphere, arr, sphere_path)  # vtable[0x80] (308782)

        # THE PICK — iterate IN ORDER from index 0, interior-wins-break       (308788-308825)
        *result = null
        for i in 0 .. arr.num_cells-1:
            cell = arr.cells[i].cell
            if cell == null: continue
            p = global_sphere[0].center - LandDefs::get_block_offset(pos.objcell_id, cell.cell_id)
            if cell.point_in_cell(p):                   # vtable[0x84]  (308810)
                *result = cell                          # set on ANY containing cell
                if (int16)cell.cell_id >= 0x100:        # interior?
                    sphere_path.hits_interior_cell = 1
                    break                               # INTERIOR-WINS — stop  (308819)

        # do_not_load_cells prune (308829) — OUT OF SCOPE for the flap

Load-bearing facts: current cell at index 0; pick iterates in order; breaks on the first interior cell that contains the center. ⇒ if the center is still inside the current cell, the current cell wins and the search stops — the hysteresis. An outdoor cell sets *result but does NOT break (interior can still win later). If no interior contains the center, the last containing (outdoor) cell wins.


3. find_transit_cells (the per-cell expansion, sphere variant) @ 309968 (CEnvCell)

CEnvCell::find_transit_cells(pos, num_sphere, global_sphere[], arr, sphere_path):
    exits_outside = false
    for each portal in this.portals:
        if portal.other_cell_id == 0xFFFFFFFF:         # EXIT PORTAL
            for s in 0 .. num_sphere-1:
                d = signed_dist(globaltolocal(this.frame, global_sphere[s].center), portal.plane)
                if sphere crosses the exit plane (d test w/ radius+EPSILON):
                    exits_outside = true; break
        else:
            other = portal.GetOtherCell()
            if other != null:                           # LOADED neighbour
                for s in 0 .. num_sphere-1:
                    if CCellStruct::sphere_intersects_cell(other.structure, globaltolocal(other.frame, global_sphere[s])) != OUTSIDE:
                        add_cell(arr, other.cell_id, other); break
            else:                                       # UNLOADED neighbour
                for s in 0 .. num_sphere-1:
                    if sphere on outward side of portal plane (radius+EPSILON):
                        add_cell(arr, portal.other_cell_id, null); break
    if exits_outside:
        CLandCell::add_all_outside_cells(pos, num_sphere, global_sphere, arr)   # appended AFTER interiors (310120)

acdream's FindTransitCellsSphere already mirrors this (loaded → SphereIntersectsCellBsp; unloaded → portal-plane side test; exit → exitOutside flag). Divergence kept (A6.P5, documented): acdream sets exitOutside = true unconditionally for an exit portal rather than on the plane test. Harmless under the ordered pick (outdoor only wins if no interior contains the center). Leave as-is for Stage 1.


4. check_other_cells (multi-valued collision + advance) @ 272717

check_other_cells(transition, primary_cell):
    var_4c = null
    sphere_path.cell_array_valid = 1; sphere_path.hits_interior_cell = 0
    find_cell_list(&cell_array, &var_4c, &sphere_path)         # ordered build + pick

    for i in 0 .. cell_array.num_cells-1:                      # MULTI-VALUED collision
        cell = cell_array.cells[i].cell
        if cell != null and cell != primary_cell:              # skip the already-checked primary
            r = cell.find_collisions(transition)               # vtable[0x88]
            if r in {COLLIDED, ADJUSTED}: return r
            if r == SLID: contact_plane_valid = 0; return r

    sphere_path.check_cell = var_4c                            # ADVANCE via the ordered pick (272761)
    if var_4c != null: adjust_check_pos(var_4c.id); return OK
    ... (outdoor adjust_to_outside fallback when nothing contained) ...

The cell advances ONLY here, to var_4c = the ordered current-first pick. Then validate_transition commits curr_cell = check_cell (272612) on an accepted move.


5. acdream port (the surgical change in CellTransit.cs + TransitionTypes.cs)

5.1 New type CellArray (Core/Physics) — §1

List<uint> + HashSet<uint>; Add (ordered-dedup), Contains, Count, IEnumerable<uint> (ordered), expose ordered ids as IReadOnlyList<uint>. Implement ICollection<uint> so the helper signatures can widen HashSet<uint>ICollection<uint> and existing HashSet-passing test callers still compile (they don't assert order). Retail: CELLARRAY::add_cell @701036.

5.2 BuildCellSetAndPickContaining — the verbatim §2 pick

  • Replace candidates = new HashSet<uint>() with candidates = new CellArray().
  • Indoor seed: candidates.Add(currentCellId) (index 0). BFS expand by walking the growing array by index (mirror §2's forward walk), calling FindTransitCellsSphere per cell (appends in order); AddAllOutsideCells on exitOutside (appended after).
  • Outdoor seed: AddAllOutsideCells + CheckBuildingTransit (unchanged; Stage-2 will make entry intrinsic).
  • PICK (verbatim): iterate candidates IN ORDER; for each cell, interior point-in via BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local); result = candId on any containing; interior → break (interior-wins). Outdoor fallback unchanged (gx/gy XY-column — acdream landcells lack a BSP point_in_cell).
  • DELETE the 5ca2f44 pre-check (lines ~522-539) — the ordered pick subsumes it.
  • out cellSet = candidates.OrderedIds.

5.3 Helpers re-typed

FindTransitCellsSphere, AddAllOutsideCells, AddOutsideCell, CheckBuildingTransit: HashSet<uint> candidatesICollection<uint> candidates. Bodies unchanged (candidates.Add(id), .Count, .Contains). Production passes a CellArray (ordered); tests passing new HashSet<uint>() still compile.

5.4 FindEnvCollisions (TransitionTypes.cs)

  • KEEP the line-1958 pre-derive call for Stage 1 — it is load-bearing for the outdoor→indoor seed promotion (the indoor BSP block is gated on cellLow >= 0x100; removing the pre-derive would strand an outdoor-seeded player who walks into a building). It now calls the ordered pick, so it is stable (returns the current cell when the center is in it). Removing it is Stage 2 (#4 intrinsic building entry).
  • The line-2080 FindCellSet + CheckOtherCells already does the multi-valued collision; it now receives the ordered set. No structural change.

5.5 Persistence chain — UNCHANGED (already the SetPositionInternal/change_cell equivalent)

ValidateTransition commits sp.CurCellId = sp.CheckCellId (TransitionTypes.cs:3427); ResolveWithTransition returns it via SetCurrAndReturn (writes CellGraph.CurrCell); PlayerMovementController.UpdateCellId applies it. This IS the change_cell equivalent. No change needed — the persistent-state half is already ported; only the pick was wrong.


6. Why this kills the flap (mapping to handoff §3)

  • Room ↔ room (0171↔0173↔0172, constant Z): pure pick non-determinism — multiple overlapping interior cells contain the center; the unordered HashSet returned different ones tick-to-tick. The ordered current-first pick returns the current cell every tick (index 0, interior-wins-break) → stable.
  • Vestibule ↔ outdoors (0170↔0031): while the center is inside the vestibule BSP, the current-first pick keeps 0170 (interior-wins beats the outdoor fallback) → no swing to the outdoor render path → no full-world flash.
  • Stairs ↔ cellar (0175↔0174, foot Z oscillating ~0.2 m/tick): the ordered pick dampens it while the wobble stays inside the current cell's BSP, but a genuine center crossing will still flip (retail would too — the pick uses point_in_cell). The Z-oscillation is a separate physics bug (#98 family, handoff §8). Do NOT block Stage 1 on it; if it persists after the pick fix, it is the next target.

7. Conformance tests (extend CellTransitFindCellSetTests)

  • KEEP TwoOverlappingCells_CurrentCellWinsTheStraddle (the 5ca2f44 guard) — passes under the ordered pick.
  • ADD a 3-cell straddle where the current cell wins by order (not just containment): current + two overlapping neighbours all contain the center; result == current from each seed.
  • ADD an indoor↔outdoor straddle: vestibule (interior) + outdoor landcell both contain; interior wins while it contains the center; outdoor wins only when it does not.
  • ADD a CellArray unit test: ordered append, dedup-by-id, order preserved across re-adds.
  • The deterministic membership net (CellTransit|FindEnvCollisions|CellGraph|Doorway|Cellar|DoorBug)
    • the FULL physics suite (breakage vs §10 baseline) + the visual flap gate (ACDREAM_PROBE_CELL: [cell-transit] 59 → ~6-8) are the real verification.