docs(physics): handoff reframe — membership is STATE not recomputation (user analysis)

User's own decomp dig (verified): the flap's deepest root is architectural, not the
find_cell_list pick ordering. Retail membership is persistent object STATE (curr_cell
mutated ONLY by change_cell at a portal crossing); acdream RE-DERIVES CellId from
FindCellSet geometry every tick → ping-pong. Plus multi-valued CELLARRAY (retail) vs
single CellId (acdream), uniform vs forked collision (0x0100), intrinsic vs bridge
building entry. Reframed the handoff + prompt: the pick-ordering port (§4.3) is
SUPERSEDED/symptomatic; the job is STAGE 1 = persistent + multi-valued + portal-
crossing membership (change_cell 281192, find_transit_cells, SetPositionInternal),
drop the 5ca2f44 pre-check; STAGE 2 = uniform collision + intrinsic entry. New §4.4
(the 4-point analysis) + §4.5 (staged fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 08:20:55 +02:00
parent 298b3b92b8
commit 1438d73a43

View file

@ -1,12 +1,14 @@
# Handoff — Verbatim port of retail `find_cell_list` (the R1 "flap" fix) — 2026-06-02
# Handoff — Port retail's persistent / multi-valued cell membership (the R1 "flap" fix) — 2026-06-02
> **Canonical pickup for the next (fresh) session.** Read this FIRST, then the linked
> design docs. This session shipped **R1 — the per-cell `DrawInside` render redesign** (the
> interior seal works: the cellar is solid), and that render redesign **exposed a pre-existing
> cell-MEMBERSHIP ping-pong** — the visual "flap" the user sees at every cottage threshold. The
> render is **correct**; the membership answer it consumes is **unstable**. The next job is a
> **verbatim port of retail `CObjCell::find_cell_list`'s containing-cell pick** to make
> membership stable.
> render is **correct**; the membership answer it consumes is **unstable**. The next job is to port
> retail's **persistent, multi-valued, portal-crossing cell membership** — in retail, membership is
> object *state* mutated only by `change_cell` at a portal crossing; in acdream it's a per-tick
> *geometric recomputation* (§4.4). **NOTE:** an earlier framing in this doc (a verbatim port of
> `find_cell_list`'s *pick ordering*) is **SUPERSEDED by §4.4/§4.5** — it treated a symptom.
---
@ -167,12 +169,75 @@ never flipping to an overlapping neighbour.* acdream's unordered `HashSet` disca
> fresh session can locate add_cell via Ghidra MCP (`/decompile_function`) or `acclient.h` (CELLARRAY
> = `acclient.h:31574`) if exact dedup semantics are wanted.
### 4.3 The fix (the fresh session's task)
Replace the unordered `HashSet` candidate set with an **ordered, deduped collection** (mirror
### 4.3 ⚠️ SUPERSEDED — the pick-ordering fix is TOO SHALLOW (see §4.4)
> The "ordered-CELLARRAY pick" framing below treats a **symptom**, not the root. The user's
> 2026-06-02 architectural analysis (§4.4) showed the real divergence is **membership is *state* in
> retail but a *recomputation* in acdream**. A current-first / ordered pick only makes the per-tick
> *re-derivation* stickier (a better band-aid). **Do §4.4 Stage 1 instead.** This subsection is kept
> only to explain why the pick fix is insufficient.
~~Replace the unordered `HashSet` candidate set with an **ordered, deduped collection** (mirror
retail `CELLARRAY`: current cell at index 0, neighbours in BFS add-order, unique) and port the
pick **verbatim**: iterate from index 0; for each **interior** cell, `point_in_cell` via
`BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, localCenter)`; first interior-containing →
return (break). This is the current-first hysteresis that stops the ping-pong.
return (break).~~ — insufficient: it reorders a per-tick recomputation; it does not make membership
persistent state mutated only at portal crossings.
### 4.4 THE DEEPER ROOT (user analysis, 2026-06-02) — membership is STATE, not recomputation
The user's own decomp dig (verified against the tree this session) reframes the whole task. Four
architectural differences, root → consequences:
| Aspect | Retail | acdream | Verified |
|---|---|---|---|
| **#1 Membership representation** | Persistent `curr_cell` pointer (object STATE), `acclient.h:32641` | `CellId` **recomputed per tick** from foot-sphere geometry | `PlayerMovementController.cs:1296``resolveResult.CellId``FindCellSet` pick (`TransitionTypes.cs:1958`); no `change_cell` equivalent |
| **#2 Cardinality** | A **SET** of cells (`CELLARRAY`) simultaneously; collision tested against all | Primarily **one** `CellId`; A4 `CheckOtherCells` adjacency is partial + **dormant** | handoff §1 / A4 notes |
| **#3 Collision path** | **Uniform** sphere-sweep over all cells (terrain polys + room polys, same machinery) | **Forked** at `cellLow >= 0x0100`: cell `PhysicsBSP` (+ `TryFindIndoorWalkablePlane` synth-floor workaround) indoors vs `SampleTerrainWalkable` terrain-triangles outdoors | `FindEnvCollisions` branch |
| **#4 Building entry** | **Intrinsic**`find_transit_cells``CBuildingObj::find_building_transit_cells` (`pc:318309`) adds interior cells to the same `CELLARRAY` | **Bridge hook**`CheckBuildingTransit` *promotes* the `CellId` outdoor→indoor (`0x90` stickiness workaround) | `CellTransit.CheckBuildingTransit` |
**When membership changes:** retail — only on `CObjCell::change_cell` (`pc:281192`) at a portal-plane
crossing; between crossings the pointer is simply **remembered**. acdream — every tick, wherever the
sphere geometrically lands. **That single distinction (remembered pointer vs recomputed scalar) is
why retail doorways are stable and ours ping-pong:** a push-back across a `CellBSP` boundary that does
NOT cross a portal plane changes nothing in retail; acdream re-derives and flips to outdoor.
**Precision (sharpens the target):**
- acdream's re-derivation is **seeded** (FindCellSet seeded with `sp.CheckCellId`), not from-scratch —
i.e. a *band-aided* recomputation, not persistent state.
- The persistence does **not** live in `find_cell_list` (it `num_cells=0`s + rebuilds every call,
`pc:308747`). It lives in **`curr_cell` + `change_cell` (`pc:281192`) + the transition's
portal-crossing detection** (`find_transit_cells` exit-portal flag). `find_cell_list` only supplies
the current-first seed bias. ⇒ **the port target is `change_cell` + crossing detection in the
transition, not the pick.**
### 4.5 THE FIX — STAGED (this is the fresh session's task; the `5ca2f44` pre-check is DROPPED)
**Stage 1 — the flap's root-cause fix (do this first):** port retail's **persistent, multi-valued,
portal-crossing membership.**
- Make the player's cell **sticky object state** (home: `CellGraph.CurrCell`) — mutated ONLY by a
`change_cell`-equivalent when the transition detects a **portal-plane crossing**, NOT re-derived
per tick from `FindCellSet`. Retail anchors: `CObjCell::change_cell @ 0x513390` (pc:281192),
`CEnvCell::find_transit_cells @ 0x52c820` (the exit-portal flag = the crossing signal),
`CPhysicsObj::SetPositionInternal @ 0x515330` (reads `sphere_path.curr_cell`).
- Make doorway membership **multi-valued** (the `CELLARRAY` set) so you're in outdoor+indoor
simultaneously and never flip — collision already iterates candidates (`CheckOtherCells`/A4); wake
that path and feed it the persistent set rather than a single re-derived `CellId`.
- **Delete** the `5ca2f44` current-first pre-check (band-aid on the recomputation). **Keep** its
regression test (`TwoOverlappingCells_CurrentCellWinsTheStraddle`) — it still guards a valid invariant.
- This kills the ping-pong by construction AND moots #3's flap-face (sticky membership ⇒ no flip to
"outdoor" at the threshold ⇒ the indoor BSP is always consulted there).
**Stage 2 — full faithfulness (after Stage 1 lands + the flap is gone):**
- **#3 uniform collision:** one sphere-sweep over all cells in the array (terrain polys + room polys,
same machinery); remove the `0x0100` fork + `TryFindIndoorWalkablePlane` synth-floor. Biggest piece
(acdream terrain isn't polygons-in-a-BSP today — a real rearchitecture).
- **#4 intrinsic entry:** building portals add interior cells to the array during the sweep
(`find_building_transit_cells`), replacing the `CheckBuildingTransit` promotion bridge + the `0x90`
workaround.
> **The old §4.3 ordered-pick port is NOT Stage 1.** Stage 1 is about *where membership lives and when
> it changes* (persistent state, portal crossings), not about *how the per-tick pick is ordered*.
- Thread the new ordered-collection type through the methods that build candidates:
`BuildCellSetAndPickContaining`, `FindTransitCellsSphere`, `AddAllOutsideCells`,
@ -210,12 +275,15 @@ ordered pick, **delete the pre-check** (it becomes redundant).
Per CLAUDE.md's mandatory faithful-port workflow (the triangle-Z / frame-swap lessons):
1. **Grep named — DONE.** `find_cell_list` found at pc:308742.
2. **Read decomp — DONE.** §4.2 has the verbatim pseudo-C of the pick.
2. **Read decomp.** §4.2 has the `find_cell_list` pick (CONTEXT only). The Stage-1 target is the
PERSISTENCE mechanism — read `change_cell` (pc:281192), `find_transit_cells` (the exit-portal
crossing = *when* membership changes), `SetPositionInternal` (reads `sphere_path.curr_cell`).
3. **WRITE PSEUDOCODE** (the step skipped this session): translate retail `find_cell_list`'s
candidate-build + ordered pick to readable pseudocode in `docs/research/*_pseudocode.md` before
porting. This catches misreads.
4. **PORT FAITHFULLY** line-by-line (§4.3): ordered `CellArray` + in-order current-first
interior-wins pick. Same control flow as retail. Don't "improve."
4. **PORT FAITHFULLY** line-by-line (§4.5 Stage 1): persistent `curr_cell` state mutated only by a
`change_cell`-equivalent at a portal crossing + multi-valued `CELLARRAY` membership. Same control
flow as retail. Don't "improve." (NOT the §4.3 pick-ordering — superseded.)
5. **CONFORMANCE TEST**: extend `CellTransitFindCellSetTests` — a multi-neighbour straddle where
the current cell wins by *order* (not just by containment), and an indoor↔outdoor straddle
(vestibule stays vestibule while it contains you).
@ -384,32 +452,42 @@ probe walks — diagnose from existing data + the deterministic trajectory-repla
a normal VISUAL test (their eyes) + the auto-logging ACDREAM_PROBE_CELL.
READ FIRST (in order):
1. docs/research/2026-06-02-membership-verbatim-port-handoff.md (THIS handoff — full diagnosis,
the verbatim retail source §4.2, the fix §4.3, replace-the-pre-check §5, workflow §6, KEEP §9).
1. docs/research/2026-06-02-membership-verbatim-port-handoff.md (THIS handoff — diagnosis §3; THE
DEEPER ROOT + the STAGED fix §4.4-4.5 [the §4.3 pick-ordering framing is SUPERSEDED]; KEEP §9).
2. docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md (the render redesign — context; render is CORRECT + downstream of membership).
3. docs/superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md (R1 plan — what shipped).
THE JOB:
- Replace the UNORDERED HashSet candidate set in CellTransit.BuildCellSetAndPickContaining with an
ORDERED, deduped collection (retail CELLARRAY: current cell at index 0, BFS add-order) and port the
pick VERBATIM: iterate from index 0, point_in_cell per interior cell, first interior-containing wins
(break) — retail find_cell_list pc:308788-308825. This is the current-first hysteresis that stops
the ping-pong. Thread the new type through FindTransitCellsSphere/AddAllOutsideCells/CheckBuildingTransit.
- DELETE the 5ca2f44 current-first pre-check (the ordered pick supersedes it). KEEP its regression
test (TwoOverlappingCells_CurrentCellWinsTheStraddle).
- Outdoor fallback (gx/gy XY-column) stays an acdream adaptation (landcells lack point_in_cell) — mark it.
THE ROOT (handoff §4.4 — the pick-ordering framing is SUPERSEDED): membership is *state* in retail (a
persistent curr_cell pointer mutated ONLY by change_cell at a portal crossing) but a per-tick
*recomputation* in acdream (CellId re-derived from FindCellSet geometry every tick). That
recompute-vs-remember is the ping-pong root. A pick-ordering fix only makes the recomputation
stickier — too shallow. DROP the 5ca2f44 pre-check.
WORKFLOW (physics port — mandatory): grep named (done) → read decomp (done, §4.2) → WRITE PSEUDOCODE
(docs/research/) → PORT FAITHFULLY line-by-line → CONFORMANCE TEST → run full physics suite (see
breakage vs the §10 baseline; fix new breakage) → VISUAL flap gate (room/door flap GONE; [cell-transit]
THE JOB — STAGE 1 (the flap fix): port retail's PERSISTENT, MULTI-VALUED, PORTAL-CROSSING membership.
- Make the player's cell sticky OBJECT STATE (home: CellGraph.CurrCell) — mutated ONLY by a
change_cell-equivalent when the transition detects a portal-plane crossing, NOT re-derived per tick.
Anchors: change_cell 0x513390 (pc:281192), find_transit_cells 0x52c820 (exit-portal flag = the
crossing signal), SetPositionInternal 0x515330 (reads sphere_path.curr_cell).
- Make doorway membership MULTI-VALUED (the CELLARRAY set) so you're in outdoor+indoor at once and
never flip; wake the dormant CheckOtherCells/A4 multi-cell collision and feed it the persistent set.
- DELETE the 5ca2f44 current-first pre-check (band-aid). KEEP its regression test
(TwoOverlappingCells_CurrentCellWinsTheStraddle).
STAGE 2 (after Stage 1 + flap gone): #3 uniform collision (one sphere-sweep, terrain polys + room
polys; remove the 0x0100 fork + TryFindIndoorWalkablePlane) + #4 intrinsic building entry
(find_building_transit_cells adds cells to the array; remove CheckBuildingTransit + 0x90 workaround).
WORKFLOW (physics port — mandatory): grep named → read decomp (change_cell 281192 + find_transit_cells
+ SetPositionInternal — the persistence mechanism) → WRITE PSEUDOCODE (docs/research/) → PORT
FAITHFULLY line-by-line → CONFORMANCE TEST → run full physics suite (see breakage vs the §10 baseline;
fix new breakage — breakage is AUTHORIZED) → VISUAL flap gate (room/door flap GONE; [cell-transit]
count 59→~6-8). Use superpowers:writing-plans → executing-plans; test-driven-development for conformance.
PROVEN, DON'T RE-LITIGATE: the flap is MEMBERSHIP not render (cellar seals when membership is stable;
flap tracks the [cell-transit] ping-pong). R1 render is correct — do NOT reopen it. The 2 DoorBug
TransientState failures are PRE-EXISTING (verified). The stairs flip additionally shows the foot Z
oscillating ~0.2m/tick = a SEPARATE physics issue (#98 family, §8) — if it persists after the
membership port (room/door fixed), that's the next target; don't block on it.
oscillating ~0.2m/tick = a SEPARATE physics issue (#98 family, §8) — likely mooted by sticky
membership; if it persists after Stage 1, that's the next target; don't block on it.
GOAL: stable membership → the R1 seal holds with no flap. Then resume R1b (particles) → R2
(outside-looking-in) → R3/R4 per the design spec.
GOAL: persistent portal-crossing membership (Stage 1) → the R1 seal holds with no flap → Stage 2
(uniform collision + intrinsic entry) → resume R1b (particles) → R2 (outside-looking-in) per the design spec.
```