acdream/docs/research/2026-06-02-membership-verbatim-port-handoff.md
Erik 1438d73a43 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>
2026-06-03 08:20:55 +02:00

493 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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 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.
---
## 0. THE MANDATE + the new authorization (user, 2026-06-02 — non-negotiable)
- The standing render mandate is unchanged: **fully working outdoor + indoor + dungeon rendering,
no shortcuts/bandaids, port from retail, architecturally-correct even if slower.** (See the
master render handoff + design spec, §13 links.)
- **NEW, EXPLICIT authorization (this is the key enabler):** *"You are allowed to break whatever
you want in the code to get the engine and membership working, it's OK!"* — i.e. **do the
faithful verbatim port even if it breaks other physics/movement tests or behavior.** Don't
tiptoe around the #98-area accumulated logic. Get the membership right, faithfully; fix any
breakage afterward. Run the physics suite to SEE what breaks (eyes open), don't avoid the port.
- The user also clarified the fidelity split: **physics/membership = strict, line-by-line
faithful decomp port** (this task); **rendering = retail-structured orchestration over the kept
WorldBuilder pipeline** (already done in R1 — do NOT re-port rendering from decomp).
- The user is **tired of probe-driven debug cycles** — do NOT ask them to run manual probe walks.
Diagnose from existing data + the existing apparatus (trajectory-replay harness, auto-logging
probes); verify the fix with a **normal visual test** (their eyes) + the deterministic harness.
---
## 1. THE MENTAL MODEL (render vs membership) — internalize this first
Two separate systems, in a strict producer→consumer relationship:
1. **Membership** (physics, `AcDream.Core.Physics`): every tick, answers ONE question —
*"which cell (room/space) is the player standing in?"* → a single `currentCell` per tick.
The world is carved into cells: cellar `0174`, stairs `0175`, main room `0171` (+ sub-cells
`0172`/`0173`), vestibule `0170`, outdoors `0031` (note: low id `0x31 < 0x100` ⇒ outdoor
landcell).
2. **Render** (`AcDream.App.Rendering`): `draw(currentCell)` — strictly **downstream**. If
`currentCell` is jittery, render faithfully redraws the jitter.
**The flap = render correctly drawing an oscillating membership answer.** Proof it's membership,
not render: when membership is **stable** (standing still in the cellar) the render seals
perfectly (solid walls/floor, no bleed — user confirmed). The flap appears **only** at the
boundaries where the `[cell-transit]` log shows the cell answer oscillating. **Fix membership →
render goes stable; the seal already works.**
> Confidence note (honest): *very high* the flap is membership (the cellar-seals proof + the
> flap-tracks-cell-flips evidence). NOT a claimed 100% that zero render residual exists — a single
> *clean* outdoor→indoor transition has never been observed (the ping-pong masks it), so a small
> render tear on a genuine one-time crossing *could* exist. If so it's separate + isolated, and
> becomes visible once membership is stable. The membership fix is unambiguously the right next
> step regardless.
---
## 2. WHAT THIS SESSION SHIPPED (commit ledger, branch `claude/thirsty-goldberg-51bb9b`)
| SHA | What | Keep? |
|---|---|---|
| `7aca79f` | R0 — locked render-redesign **design spec** (brainstorm outcome) | ✅ authority |
| `ce7404b` | R1 **implementation plan** (per-cell DrawInside, TDD) | ✅ authority |
| `cf85ea4` | R1 Task 1 — `InteriorEntityPartition` (3-bucket entity split, TDD, 3 tests) | ✅ correct |
| `4b75c68` | R1 Task 2 — `InteriorRenderer` per-cell `DrawInside` loop | ✅ correct |
| `c4fd711` | R1 Task 3 — **binary render decision** in `GameWindow.OnRender` (indoor = DrawInside only) | ✅ correct |
| `58822fe` | R1 Task 4 — repurpose the `WbDrawDispatcher.cs:1756` `ParentCellId==null` bypass (#78) | ✅ correct |
| `5ca2f44` | **membership pre-check approximation** (current-cell-first explicit check) | ⚠️ **REPLACE** (see §5) |
Working tree is **clean** at `5ca2f44` (untracked = session screenshots + launch logs only).
R1 (the render redesign) is done and **proven correct by the visual gate** (cellar seals). The
only open R1 blocker is the membership flap (this handoff).
**Design + plan docs (read after this handoff):**
- Design spec: [`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`](../superpowers/specs/2026-06-02-render-pipeline-redesign-design.md)
- R1 plan: [`docs/superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md`](../superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md)
- Master render handoff: [`docs/research/2026-06-02-render-pipeline-redesign-handoff.md`](2026-06-02-render-pipeline-redesign-handoff.md)
- Retail render reference: [`docs/research/2026-06-02-retail-render-pipeline-full-reference.md`](2026-06-02-retail-render-pipeline-full-reference.md)
---
## 3. THE PROVEN DIAGNOSIS (the flap = membership ping-pong)
Captured from the user's R1 walk (`ACDREAM_PROBE_CELL`, `[cell-transit]` lines). **59 cell
transitions in one cottage walk** (a clean walk should be ~68) — oscillating at every boundary:
- **Stairs ↔ cellar** (`0175 ↔ 0174`) at ~(154.3, 9.4, 93.1). Crucially, the **foot Z oscillates
~0.2 m/tick** (93.07 ↔ 93.27; low Z = cellar, high Z = stairs). ⇒ membership is faithfully
following a *bouncing position* — this looks like a **SEPARATE stairs/ramp physics instability**
(step-up/step-down, #98 family), not (only) the pick. **See §8 — do not conflate.**
- **Room ↔ room** (`0171 ↔ 0173 ↔ 0172`) at **constant Z = 94.0**, tiny X/Y movement. ⇒ pure
membership-**pick** non-determinism (the unordered HashSet). This is the verbatim-port target.
- **Vestibule ↔ outdoors** (`0170 ↔ 0031`) at ~(155, 16). `0031` is an outdoor landcell, so each
flip swings the binary render decision to the *outdoor* path → the full sky/world/NPCs flash
("bluish background"). Fixed by the same current-first hysteresis (vestibule wins while it
contains you).
The `[vis]` log confirms the mechanism: as the root cell flips, the `OutsideView` + visible-cell
set flip with it (e.g. room `0171``outside(polys=0)` sealed vs stairs `0175`
`outside(polys=1)` sky-through-portal), so the through-door landscape appears/disappears = the flap.
Evidence files (this session, may still be present in the worktree root): `launch-r1.log`,
`launch-fix.log` (UTF-16 — read with `Select-String` / ripgrep `--encoding utf-16-le`, NOT GNU grep).
---
## 4. ROOT CAUSE + the verbatim retail target
### 4.1 acdream (the bug)
`CellTransit.BuildCellSetAndPickContaining` (`src/AcDream.Core/Physics/CellTransit.cs`, the body
of `FindCellSet`):
- The candidate set is an **unordered `HashSet<uint>`** (declared ~line 433).
- The current cell IS added first (~line 447, indoor seed) — **but `HashSet` does not preserve
insertion order**, and the set's contents churn tick-to-tick at a boundary, so the enumeration
can surface a neighbour before the current cell.
- The pick (the `foreach (uint candId in candidates)` interior pass, ~lines 544562 post-`5ca2f44`):
iterates the HashSet in arbitrary order, returns the **first** interior cell whose `CellBSP`
contains the sphere center (interior-wins). Outdoor fallback = a `gx/gy` XY-column computation
(NOT retail's `point_in_cell` on landcells — acdream landcells have no `point_in_cell`).
- The player's `CellId` is set from this: `ResolveWithTransition` (`PhysicsEngine.cs:608`) returns
the swept `sp.CurCellId` (lines 880/901), which `FindEnvCollisions` (`TransitionTypes.cs:1958`)
sets from `FindCellSet`; `PlayerMovementController.cs:1296` does `UpdateCellId(resolveResult.CellId,
"resolver")`. **A1 (swept membership) IS ported — the divergence is purely the pick ordering.**
### 4.2 retail (the verbatim source) — `CObjCell::find_cell_list @ 0x52b4e0` (pc:308742308831)
Reads verbatim this session. The structure:
```c
// edi = arg4 = the CELLARRAY (an ORDERED DArray<CELLINFO{cell_id, cell*}>)
num_cells = 0; added_outside = 0;
objcell_id = arg1->objcell_id; // the CURRENT cell
cell0 = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) : CLandCell::GetVisible(objcell_id);
if (objcell_id >= 0x100) CELLARRAY::add_cell(edi, objcell_id, cell0); // CURRENT at INDEX 0 (pc:308766)
else CLandCell::add_all_outside_cells(arg1, arg2, arg3, edi);
// expand: for each cell already in the array, call its find_transit_cells (vtable[0x80]) (pc:308782)
for (i = 0; i < edi->num_cells; i++)
if (edi->cells[i].cell) edi->cells[i].cell->vtable[0x80](arg1, arg2, arg3, edi, arg6);
// THE PICK (pc:308788308825): iterate the array IN ORDER from index 0, interior-wins-break
*arg5 = nullptr;
for (i = 0; i < edi->num_cells; i++) {
cell = edi->cells[i].cell;
if (cell) {
// point relative to the cell's block offset:
Vector3 p = arg3->center - LandDefs::get_block_offset(arg1->objcell_id, cell->cell_id);
if (cell->vtable[0x84](&p) != 0) { // point_in_cell (pc:308810)
*arg5 = cell; // set result on ANY containing cell
if ((int16_t)cell->cell_id >= 0x100) { // interior?
arg6->hits_interior_cell = 1;
break; // INTERIOR-WINS — stop
}
}
}
}
// (then do_not_load_cells prune, pc:308829+ — out of scope for the flap)
```
**The load-bearing facts:** (a) the current cell is at **index 0**; (b) the pick iterates **in
order** and **breaks on the first interior-containing cell**. So **the current cell is tested
FIRST — if you're still inside it, it wins and the search stops.** That ordered, current-first
iteration IS the hysteresis: *you stay in your current cell until the center genuinely leaves it,
never flipping to an overlapping neighbour.* acdream's unordered `HashSet` discarded that ordering.
> `CELLARRAY::add_cell` definition was not located by grep (`void CELLARRAY::add_cell(` → no match;
> only call sites at pc:279012/288076/308766/309860/309960/310030/310054/310208/317064/317218).
> Behaviorally it appends to the ordered array; treat the candidate collection as **ordered +
> deduped** (the HashSet already dedups; an ordered-dedup collection is the faithful model). The
> 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 ⚠️ 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).~~ — 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`,
`CheckBuildingTransit` (they currently take `HashSet<uint> candidates`). Changing the type is the
invasive part the user authorized.
- **Honest scope:** the **interior** pick is fully verbatim-portable. The **outdoor fallback**
(the `gx/gy` XY-column) stays an acdream adaptation — acdream landcells lack retail's
`CLandCell::point_in_cell` (they're a terrain grid). Mark it clearly. The flap is all
interior/boundary, so the verbatim interior pick covers it.
- Suggested collection: a small `CellArray` class — `List<uint>` (order) + `HashSet<uint>` (O(1)
dedup); `Add(id)` appends iff new; ordered enumeration; `Contains`; `Count`. Exposes its list as
`IReadOnlyCollection<uint>` for the `out cellSet` return.
---
## 5. REPLACE the committed pre-check approximation (`5ca2f44`)
`5ca2f44` added an **explicit current-cell-first pre-check** in `BuildCellSetAndPickContaining`
(before the interior-pass `foreach`): if the current cell is interior and its `CellBSP` contains
the center, return it. This achieves the *property* but NOT retail's ordered-array *structure*, and
it still diverges on the multi-neighbour edge (hash-order among non-current candidates). **The user
wants this replaced by the verbatim ordered-`CELLARRAY` port (§4.3).** When you implement the
ordered pick, **delete the pre-check** (it becomes redundant).
- **Keep** the regression guard test added in `5ca2f44`:
`CellTransitFindCellSetTests.TwoOverlappingCells_CurrentCellWinsTheStraddle` (two-direction
`[Theory]`). It documents the current-cell-first invariant and passes under the verbatim port.
Note: it does **not** go RED against the bug statically (the HashSet happens to enumerate the
current cell first when the set is small/unchurned — see §7), so it's a guard, not the RED repro.
The real verification is the harness + the visual flap gate.
---
## 6. THE WORKFLOW for the fresh session (this is a PHYSICS port)
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.** §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.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).
6. **VERIFY** (no manual probe walks): the deterministic harness (`CellTransitFindCellSetTests`,
`CellarUpTrajectoryReplayTests`, `DoorBugTrajectoryReplayTests`), then **run the FULL physics
suite to see breakage** (`dotnet test tests/AcDream.Core.Tests`), then the **visual flap gate**
(user walks normally; `ACDREAM_PROBE_CELL` auto-logs so you can confirm the transition count
drops 59 → ~68 without asking them to do anything extra).
**Superpowers:** you may go straight to `superpowers:writing-plans``superpowers:executing-plans`
for the port (the design is clear — a heavy `brainstorming` pass is probably unnecessary). Use
`superpowers:systematic-debugging` if the flap doesn't fully clear and you need to chase a residual.
Use `superpowers:test-driven-development` for the conformance tests.
---
## 7. WHY the static unit test alone won't catch it (important nuance)
The pre-check / the bug interplay: `.NET HashSet<uint>` with a small, unchurned set tends to
enumerate in insertion order, and the current cell is added first — so a *static* single-tick
test where the current cell contains the center already returns the current cell (the
`TwoOverlappingCells` guard PASSES even against the unfixed pick). The production ping-pong is
**dynamic** — it arises from (a) the candidate set's contents churning tick-to-tick (reordering
the enumeration), and/or (b) the foot position genuinely oscillating across a boundary (the stairs
Z-jitter, §8). The **ordered `CELLARRAY`** removes (a) by construction (deterministic current-first
order every tick). (b) is the separate physics issue. ⇒ verify dynamically (harness + visual),
not just by the static guard.
---
## 8. The SEPARATE stairs-physics suspicion (don't conflate with the pick)
On the stairs the foot **Z oscillated ~0.2 m/tick** while membership flipped `0175↔0174`. The room
flips were at **constant Z** (pure pick). So:
- The **room/vestibule** flips → fixed by the verbatim current-first pick (this task).
- The **stairs** flip may be a **separate physics-movement instability** (step-up/step-down on the
cellar stairs/ramp — the #98 family: "stuck at the last step", `Transition.AdjustOffset` /
`DoStepUp`). The current-first hysteresis will *dampen* it if the wobble stays inside the current
cell's BSP, but if the Z-oscillation is large enough to cross the cell boundary it will persist.
- **If the stairs still flap after the membership port lands and the room/door flaps are gone**,
that's the physics-movement target (a #98-area follow-up) — diagnose it the same evidence-first
way (the existing `ACDREAM_CAPTURE_RESOLVE` + the trajectory-replay harness; the user's
cellar-stairs is the repro). Do not block the membership port on it.
---
## 9. KEEP / DON'T-REDO (avoid re-litigating settled work)
**KEEP (correct, do not reopen for the flap):**
- All R1 render code: `InteriorEntityPartition`, `InteriorRenderer` (per-cell DrawInside loop),
the binary decision in `GameWindow.OnRender`, the `WbDrawDispatcher` gate fix. The cellar seals
— render is correct.
- `PortalVisibilityBuilder`, `ClipFrameAssembler`/`ClipFrame`, `EnvCellRenderer`, `TerrainModernRenderer`,
the WB mesh pipeline. The render design spec + R1 plan.
- The swept-membership chain (A1): `ResolveWithTransition` returning `sp.CurCellId`. Do NOT revert
to a static `ResolveCellId` re-derive.
- The `5ca2f44` regression test (`TwoOverlappingCells_CurrentCellWinsTheStraddle`).
**DON'T:**
- Don't reopen R1's render code chasing the flap — the flap is membership.
- Don't re-port *rendering* from the decomp (use the WB pipeline — CLAUDE.md).
- Don't add a render-side debounce/grace-period for the flap (bandaid — forbidden).
- Don't ask the user for manual probe walks (diagnose from existing data + harness; verify visually).
---
## 10. TEST STATE (baseline for regression judgement)
- **Pre-existing Core failures (NOT yours — verified):** the handoff's "5" — 2 step-up gaps (incl.
an A6.P4 door regression) + 3 door-collision apparatus / A6.P5. The **2
`DoorBugTrajectoryReplayTests` failures** (`TransientState live=0x87 harness=0x83`, e.g.
`LiveCompare_DoorBlocksFromOutside_Tick22760`) were **verified pre-existing this session** (they
fail without the membership change too). Plus the documented PhysicsResolveCapture/PhysicsDiagnostics
**static-leak flakiness** (819 failures across runs of identical code). ⇒ the deterministic
**membership net** (`CellTransit|FindEnvCollisions|CellGraph|Doorway|Cellar|DoorBug`) is the
reliable signal: it was **66 pass + 2 pre-existing DoorBug** with the pre-check.
- `tests/AcDream.App.Tests`: **174 green** (incl. `InteriorEntityPartitionTests` ×3, the flipped
`EntityClipTests` gate test).
- **Breakage is authorized** (§0): when the verbatim port lands, run the full physics suite, diff
the failure set against this baseline, and fix genuinely-new breakage (the port may legitimately
change membership-dependent test expectations — update them with retail-cited reasoning, don't
pin wrong values).
---
## 11. After the membership port: the remaining render arc
Once the flap is gone and the seal holds, resume the R1→R7 plan (design spec §7):
- **R1b** — per-cell particles (#104): "particles bleed through the ground when looking out the
door" — Scene-pass particles aren't cell-clipped (needs a cell link on `ParticleEmitter`; the
per-cell `DrawInside` loop makes this tractable).
- **R2** — outside-looking-in (`DrawPortal`): "interior walls are transparent when looking in a
window/door from outside" — no outdoor→interior portal render yet.
- **R3** — dungeons. **R4** — polish (the `CullMode.Landblock→None` winding; remove dormant WB
two-pipe scaffolding `Building`/`BuildingLoader`; conformance).
- The cottage seal (R1) is **not signed off** until the flap is gone — the membership port is the
R1 gate's remaining blocker.
---
## 12. KEY FILES + ANCHORS (quick index)
```
MEMBERSHIP (the task)
src/AcDream.Core/Physics/CellTransit.cs
FindCellSet (entry, ~388 single-sphere, ~412 multi-sphere)
BuildCellSetAndPickContaining (the candidate build + pick — THE function to port)
~433 candidates = new HashSet<uint>() ← the unordered set to replace
~447 candidates.Add(currentCellId) ← current added first (indoor seed)
~520 the 5ca2f44 pre-check ← DELETE when the ordered pick lands
~544 foreach interior pass (the pick) ← port verbatim (ordered, current-first)
~558 gx/gy outdoor fallback ← acdream adaptation (landcells lack point_in_cell)
FindTransitCellsSphere / AddAllOutsideCells / CheckBuildingTransit ← take `HashSet<uint> candidates` (re-type)
src/AcDream.Core/Physics/TransitionTypes.cs:1958 ← FindEnvCollisions calls FindCellSet (the swept membership)
src/AcDream.Core/Physics/PhysicsEngine.cs:608/880/901← ResolveWithTransition returns swept sp.CurCellId
src/AcDream.App/Input/PlayerMovementController.cs:1296 ← UpdateCellId(resolveResult.CellId, "resolver")
tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs ← conformance home (+ the kept guard)
(line numbers ≈ — shifted by 5ca2f44's ~22 lines; grep by method name)
RETAIL DECOMP (the verbatim source)
CObjCell::find_cell_list 0x52b4e0 pc:308742 (pick = 308788308825; add_cell @308766; point_in_cell vtable[0x84] @308810; find_transit_cells vtable[0x80] @308782)
CEnvCell::find_transit_cells 0x52c820 pc:309968
acclient.h: CELLARRAY 31574 ; CELLINFO 31925 ; SPHEREPATH 32625
Ghidra MCP (port 8081, patchmem.gpr) for /decompile_function on add_cell if exact dedup wanted.
RENDER (correct — context only)
src/AcDream.App/Rendering/InteriorRenderer.cs ← per-cell DrawInside loop
src/AcDream.App/Rendering/InteriorEntityPartition.cs ← 3-bucket entity split
src/AcDream.App/Rendering/GameWindow.cs (OnRender ~7530)← the binary decision
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:~1744 ← EntityPassesVisibleCellGate (gate fix)
```
---
## 13. RUNNING THE CLIENT + apparatus (no manual probe walks)
Per CLAUDE.md "Running the client" (PowerShell; `+Acdream` spawns at/near the Holtburg cottage):
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_PROBE_CELL="1" # auto-logs [cell-transit]; lets you confirm the count drops, no manual walk
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 | Tee-Object -FilePath launch.log
```
- Build green BEFORE launching. Logs are UTF-16 (`Select-String` / ripgrep `--encoding utf-16-le`).
- A client from THIS session may still be running (graceful close clears ACE in ~35s; a hard kill
leaves the session stuck ~3 min — see CLAUDE.md). Close gracefully before relaunching.
- Verify the flap visually (user's eyes) + the `[cell-transit]` count. Walk: outside `0031` → door
`0170` → room `0171` → stairs `0175` → cellar `0174`, and back. Room/door flap should be GONE.
---
## 14. PICKUP PROMPT (copy-paste for the fresh session)
```
VERBATIM PORT of retail CObjCell::find_cell_list's containing-cell pick — to fix the cell-MEMBERSHIP
ping-pong that the R1 render redesign exposed (the cottage "flap"). Continue on branch
claude/thirsty-goldberg-51bb9b (do NOT branch/worktree; do NOT push without asking; NEVER git
stash/gc — a shared stash is under investigation). PowerShell on Windows; launch logs are UTF-16
(Select-String / ripgrep --encoding utf-16-le, NOT GNU grep).
AUTHORIZATION (user, explicit): you may BREAK ANY physics/movement code or tests to get the engine +
membership working faithfully — breakage is OK, fix later. Do the faithful line-by-line port; don't
tiptoe around the #98-area logic. Run the physics suite to SEE what breaks. The user is tired of
probe walks — diagnose from existing data + the deterministic trajectory-replay harness; verify with
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 — 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 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.
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) — likely mooted by sticky
membership; if it persists after Stage 1, that's the next target; don't block on it.
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.
```