docs(render): Unified Cell Graph pivot — evidence model + Stage 1 spec

Pixel-grounded investigation concluded the indoor 'world from below' is a cell-MEMBERSHIP disagreement between render-side CellVisibility and physics-side ResolveCellId, not any single draw gate (terrain has one gated draw path; it leaks only on render null-root frames). Decision with user: full migration onto one retail CObjCell graph across physics+collision+render+streaming, staged in 5 verify-each cycles. This lands the evidence model + the Stage 1 (ObjCell scaffold) design. No code yet.

- docs/research/2026-06-02-render-cell-membership-evidence.md (the why, from pixels)

- docs/superpowers/specs/2026-06-02-unified-cell-graph-stage1-design.md (Stage 1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 08:27:00 +02:00
parent 1d7d8b1de4
commit e8c7164ad9
2 changed files with 347 additions and 0 deletions

View file

@ -0,0 +1,227 @@
# Unified Cell Graph (CObjCell) — Stage 1: ObjCell Scaffold
**Status:** design approved 2026-06-02 (brainstorm). Implementation pending plan.
**Branch:** `claude/thirsty-goldberg-51bb9b` (unpushed).
**Evidence:** `docs/research/2026-06-02-render-cell-membership-evidence.md`.
**Supersedes:** the abandoned A8 two-pipe (issue #103) and the render-only framing of
"Phase U"; expands that intent to the full retail cell model.
**Roadmap:** register this program as a phase in `docs/plans/2026-04-11-roadmap.md`
when Stage 1 implementation begins (id TBD with user — referred to here as "UCG").
---
## 1. Why (motivation)
acdream renders the world through **two independent cell systems** that disagree:
a render-side one (`CellVisibility` + `PortalVisibilityBuilder` + 3 inconsistent
draw gates) and a physics-side one (`PhysicsEngine.ResolveCellId` +
`PhysicsDataCache`). Standing inside the Holtburg cottage, the interior fails to
seal and the outdoor world renders ("world from below"). Pixel-grounded evidence
(see evidence doc) proved the root cause is **membership disagreement**, not any
single gate: physics knows you're in indoor cell `0xA9B40174` the instant you spawn,
while the render side fires zero visibility probes; terrain (which has exactly one,
gated draw path) leaks only on frames where the render root resolves outdoor/null.
Retail has no such split. It has **one** abstraction — `CObjCell` — with a single
`curr_cell` per object; outdoor terrain cells (`CLandCell`) and indoor rooms
(`CEnvCell`) are the same kind of thing in one graph, and crossing a doorway is a
pointer swap, not a mode switch. **Seamlessness is the absence of a second system.**
## 2. The program (full migration, staged)
Scope decided with the user (2026-06-02): **full migration** onto one `CObjCell`
graph across physics + collision + render + streaming; the M0 freeze is lifted for
whatever this needs. Executed as an ordered chain — each stage its own
spec→plan→implement→verify cycle, each leaving the client runnable + visually
verified, never one long broken stretch:
| Stage | What | Verify | Risk |
|---|---|---|---|
| **1 — `ObjCell` scaffold (THIS spec)** | New Core model + graph + `GetVisible`, built alongside today's systems, consumed by nobody. | Builds green; **zero behavior change**; Core unit tests. | Low |
| 2 — One membership | Player `curr_cell` via retail `find_cell_list` + `change_cell` + doorway hysteresis; collapses `ResolveCellId` and `FindCameraCell` into one answer. | No inn-doorway ping-pong. | Med |
| 3 — Render on the graph | PView walk from `curr_cell`; **one gate** for terrain/shells/entities; bounded traversal. Retires `CellVisibility`/`LoadedCell` + the 3 gates. | Cottage + dungeon seal (the M1.5 win). | Med |
| 4 — Collision on the graph | Physics collision queries the same `ObjCell`s; retire parallel `CellPhysics`. | A6 conformance set green. | High |
| 5 — Streaming → `ObjCell`s | Streaming loads/unloads cells uniformly; terrain becomes `LandCell`s; wrappers removed. | Streaming + N.6 perf baseline hold. | High |
The visible indoor fix lands at **Stage 3**; Stages 45 finish the foundation so the
seam is gone by construction.
## 3. Retail grounding (the model we port)
From the decomp survey (anchors cited). Hierarchy:
`CObjCell → CSortCell → CLandCell` and `CObjCell → CEnvCell`.
- **`CObjCell`** (`acclient.h:30915`): id (`m_DID.id` — **magnitude is the type
discriminator**: `<0x100` land, `≥0x100` env), `pos`, `object_list`,
`shadow_object_list` (the per-cell **collision** membership list — Stage 4),
`stab_list` (visibility-reachable ids; doubles as the hysteresis keep-set),
`seen_outside`, `myLandBlock_`. Vtable seams: `+0x80 find_transit_cells`,
`+0x84 point_in_cell`, `+0x88 find_collisions`.
- **`CSortCell`** (`acclient.h:31880`): adds `building` — the outdoor→indoor bridge.
- **`CLandCell`** (`acclient.h:31886`): `polygons`, `in_view`. Resolved positionally
via `LScape::get_landcell`.
- **`CEnvCell`** (`acclient.h:32072`): `structure` (a `CellStruct`, `:32275`, holding
vertices + **three distinct BSPs**: drawing / physics / cell-containment),
`portals` (`CCellPortal[]`), `static_objects`, `portal_view` (per-frame recursive
clip stack — Stage 3). Resolved via a hash table keyed by id.
- **`CCellPortal`** (`acclient.h:32300`): `other_cell_id`, `other_cell_ptr` (cache),
`portal` (the doorway polygon/plane), `portal_side`, `other_portal_id`,
`exact_match`. `GetOtherCell` = `CEnvCell::GetVisible(other_cell_id)`.
- **`CBldPortal`** (`acclient.h:32094`) on `CBuildingObj`: outdoor→interior entry
portals (`other_cell_id`, `other_portal_id`, `stab_list`, `sidedness`).
- **`GetVisible(id)`** (`pseudo_c:308209`): `id==0`→null; `≥0x100``CEnvCell` hash;
else→`CLandCell` positional. The one resolver the whole engine uses.
- **`change_cell`** (`pseudo_c:281192`): `leave_cell(old)` then `enter_cell(new)`
a pointer swap maintaining `object_list` + `PhysicsObj.cell`. (Stage 2.)
- **`find_cell_list`** (`pseudo_c:308742`): position+sphere → cell set. **Anti-ping-pong
is NOT a remembered prev-cell** — it's the persistent `CellArray` (lives on the
`Transition`) + the `do_not_load_cells` prune to `{anchor} {anchor.stab_list}`.
(Stage 2.)
- **`ConstructView`/`InitCell`** (`pseudo_c:433750`/`433827`/`432896`): recursive
per-portal clipping (root → InitCell builds clip region → for each visible portal,
clip parent view ∩ portal polygon → `GetVisible(other_cell_id)` → push clip onto
neighbor's `portal_view` → recurse). Exit-portal-to-outside via `outdoor_portal_list`.
This is the *real* PView, NOT WB's flat stencil. (Stage 3.)
## 4. acdream current state (what we wrap/absorb)
From the inventory (file:line cited). **No `ObjCell` exists today.** Two worlds:
- **Render:** `LoadedCell` (`src/AcDream.App/Rendering/CellVisibility.cs:24-105`) —
`CellId`, `WorldTransform`/`InverseWorldTransform`, `LocalBoundsMin/Max`, `Portals`
(`CellPortalInfo`: `OtherCellId,PolygonId,Flags,OtherPortalId`), `ClipPlanes`,
`PortalPolygons`, `BuildingId`, `VisibleCells` (stab-list), `SeenOutside`. Stored in
`_cellLookup` (full id) + `_cellsByLandblock` (upper-16). **Closest match to retail
`CEnvCell`** but lives in the App layer.
- **Physics:** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:511-564`) —
`BSP`, `PhysicsPolygons`, `CellBSP` (`DatReaderWriter.Types.CellBSPTree`), `Portals`
(`PortalInfo`**no `OtherPortalId`**), `PortalPolygons`, transforms. Has the BSPs,
lacks bounds/`SeenOutside`/`OtherPortalId`. Plus `BuildingPhysics`+`BldPortalInfo`
(`BuildingPhysics.cs`) and legacy `CellSurface` (`CellSurface.cs` — to retire).
- **Terrain:** no per-landcell object. `TerrainSurface`
(`src/AcDream.Core/Physics/TerrainSurface.cs`) is **one per landblock** (8×8 cells)
+ `ComputeOutdoorCellId(localX,localY)` (`:510`). A `LandCell` must be **synthesized**.
- **Membership:** `PlayerMovementController.CellId` (bare `uint`,
`PlayerMovementController.cs:133`) re-resolved each tick by
`PhysicsEngine.ResolveCellId` (`PhysicsEngine.cs:253`), branching at `0x0100`.
- **Registration divergence:** render `LoadedCell` is staged to `_pendingCells` and
committed on the **render thread** (`AddCell`, `GameWindow.cs:5749`); physics
`CacheCellStruct` commits on the **worker thread** (`GameWindow.cs:5489`). Plus a
**+2 cm render Z-lift** (`GameWindow.cs:5463-5464`) and a **null-BSP drop** in
physics (`PhysicsDataCache.cs:158-159`) → the render cell set is a *superset*.
**Layer constraint (decisive):** `AcDream.Core` must not depend on `AcDream.App`/GL
(Code Structure Rule 2). `LoadedCell` is App; `CellPhysics`/`TerrainSurface` are Core;
neither alone is a complete `CEnvCell`. Therefore the unified model **must live in Core
and cannot literally wrap the App `LoadedCell`.**
## 5. Stage 1 design
### 5.1 Layer decision (approved: Approach A)
The `ObjCell` graph lives in **`AcDream.Core`** and owns the **CPU cell model**;
**App keeps GL** mesh handles keyed by cell id and reads the Core graph to decide what
to draw + clip (Stage 3). GL never enters Core. The two legacy objects are absorbed
over stages (`LoadedCell` CPU role retired at Stage 3; `CellPhysics` folded at Stage 4).
Rejected alternatives: **B** (`ObjCell` as a Core interface both objects adapt to — keeps
two objects bridged, never yields one `curr_cell`); **C** (move `LoadedCell` into Core —
drags render concepts `ClipPlanes`/stencil polys into Core).
This also resolves three inventory divergences: the **+2 cm lift** (Core holds one
physics-verbatim transform; render applies the lift downstream), the **null-BSP drop**
(the graph includes *all* cells; `PointInCell` on a BSP-less cell falls back to AABB),
and the **three keying schemes** (one graph, one key).
### 5.2 Types (`namespace AcDream.Core.World.Cells`)
Sketch (final signatures in the plan):
```csharp
abstract class ObjCell {
uint Id { get; }
bool IsEnv => (Id & 0xFFFF) >= 0x100; // retail magnitude dispatch
Matrix4x4 WorldTransform, InverseWorldTransform; // physics-verbatim (no render lift)
Vector3 LocalBoundsMin, LocalBoundsMax;
IReadOnlyList<CellPortal> Portals;
IReadOnlyList<uint> StabList;
bool SeenOutside;
abstract bool PointInCell(Vector3 worldPoint); // retail +0x84 seam
}
sealed class EnvCell : ObjCell { // built from the dat CellStruct
CellBSPTree? ContainmentBsp; // PointInCell → existing BSPQuery; null → AABB
// physics-BSP reference reserved for Stage 4; static objects stay entity-side
}
sealed class LandCell : ObjCell { // SYNTHESIZED from TerrainSurface
TerrainSurface Terrain; int Cx, Cy; // PointInCell = 24 m quad XY test
uint? BuildingCellId; // CSortCell bridge ref (logic = Stage 2)
}
readonly struct CellPortal { // unified superset of the 3 portal types
uint OtherCellId; ushort OtherPortalId, PolygonId, Flags; bool PortalSide;
Vector3[] PolygonLocal; // carried now; consumed by PView at Stage 3
}
sealed class CellGraph {
void Add(EnvCell);
void RegisterTerrain(uint landblockId, TerrainSurface);
void RemoveLandblock(uint landblockId);
ObjCell? GetVisible(uint id); // 0→null; env→hash; land→synthesize
ObjCell? Neighbor(ObjCell cell, in CellPortal p); // = GetVisible(p.OtherCellId)
ObjCell? CurrCell { get; } // holder defined here; INERT until Stage 2
}
```
### 5.3 Population
Built **alongside** existing registration from the **same dat data**, in Core:
- In `CacheCellStruct` (worker thread) also build + `CellGraph.Add(envCell)` — derive
bounds + full portals (`OtherPortalId`) + `SeenOutside` from the same `CellStruct`.
- In `AddLandblock` also `CellGraph.RegisterTerrain(lbId, terrainSurface)`.
- `LandCell`s are **synthesized on lookup** (not stored), like retail's positional
`LScape` resolution.
- **Every** cell is included — no null-BSP drop.
### 5.4 What Stage 1 does NOT do (YAGNI boundary)
- No membership driving — `CurrCell` has no writer (Stage 2).
- No render consumption — `CellVisibility`/`LoadedCell` untouched (Stage 3).
- No collision consumption — `ResolveCellId`/`PhysicsDataCache` untouched (Stage 4).
- No streaming-production change — built beside existing registration (Stage 5).
- No PView/clipping, no `find_transit_cells`/`find_collisions`/shadow lists.
- **The graph is built and tested but consumed by nobody.** Behavior is unchanged.
## 6. Testing (pure Core unit tests, no GL)
Use the **real cottage cell fixtures from the #98 harness** (`0xA9B4014x`) + the
cottage GfxObj fixture (`0x01000A2B`):
- `GetVisible` dispatch: `0`→null; `0xA9B40174`→the `EnvCell`; an outdoor id→a
synthesized `LandCell`.
- Topology: portals resolve to neighbors; `OtherPortalId` reciprocity holds.
- `PointInCell`: a point inside the cottage cell → true, just outside → false
(delegating to the containment BSP); `LandCell` 24 m quad test; BSP-less cell falls
back to AABB.
- Bounds/transform round-trip (world↔local).
- Population/eviction: registering a landblock builds the expected set;
`RemoveLandblock` clears it.
## 7. Acceptance criteria
- `dotnet build` green; `dotnet test` green (new Core tests pass; existing suite
unchanged — the baseline static-leak flakiness noted in CLAUDE.md is the only allowed
variance).
- **Zero behavior change**: launching the client renders identically to baseline
`9bff2b0` (the graph is consumed by nobody). Spot-check the cottage looks the same
(still broken — that's expected; Stage 3 fixes it).
- Code-structure: all new types in `AcDream.Core.World.Cells`; no App/GL dependency
added to Core.
## 8. Risks
- **Leaky half-migration** (the standing risk for the whole program): mitigated by the
crisp `ObjCell` interface as the seam — render + membership go through it; collision +
streaming stay behind their current adapters until Stages 45.
- **Dat-derivation drift**: deriving bounds/portals/`SeenOutside` in Core must match what
the App `BuildLoadedCell` computes today, or Stage 3's switch-over changes behavior.
Mitigation: a conformance test comparing Core-derived fields against the App values for
the fixtures (one-off, can be deleted after Stage 3).
- **Scope creep into Stage 2+**: enforce §5.4 ruthlessly — Stage 1 ships an inert graph.
## 9. Next
On approval → `writing-plans` produces the Stage 1 implementation plan
(subagent-friendly tasks, RED→GREEN tests, small sequential commits). Stages 25 each
get their own spec at the start of their cycle.