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>
This commit is contained in:
parent
1438d73a43
commit
b44dd147bc
4 changed files with 992 additions and 0 deletions
|
|
@ -0,0 +1,241 @@
|
|||
# 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`](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> candidates` → `ICollection<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.
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue