diff --git a/docs/research/2026-06-02-retail-cell-render-research-prompt.md b/docs/research/2026-06-02-retail-cell-render-research-prompt.md new file mode 100644 index 0000000..a631b3e --- /dev/null +++ b/docs/research/2026-06-02-retail-cell-render-research-prompt.md @@ -0,0 +1,129 @@ +# Research task — Retail AC: cell transitions, underground/dungeons, and seamless inside/outside rendering + +> **Shared prompt for a multi-model study (2026-06-02).** The same prompt is run on +> several models (Opus 4.6/4.7/4.8, Sonnet 4.6, and an external model) so we can compare +> independent reads before committing to an architecture. Study the **source** (retail +> decomp + reference repos) and **cite everything** — do not guess. Depth + citations +> matter far more than brevity. + +## 0. Why this study exists (context) + +**acdream** is a modern C# .NET port of the retail Asheron's Call client (Sept 2013 EoR +build). The rule: *the code is modern, the behavior is retail.* Every AC-specific algorithm +is ported faithfully from the named retail decomp. + +We are at an architecture decision and want ground truth before choosing. Two coupled +problems: + +1. **Cell-membership flicker (physics).** The player's "current cell" ping-pongs at + boundaries — at a near-static position the cell flips e.g. `0xA9B40170` (indoor cottage + vestibule) ↔ `0xA9B40031` (outdoor landcell) every physics tick, and also + `vestibule ↔ room` and inside the cellar. Root finding so far: acdream runs retail's + collision *sweep* but then **discards the swept cell** and **re-derives the cell from the + final static position every tick** (`PhysicsEngine.ResolveCellId`), which flips as the + collision push-back jitters the end position ±~8 cm across the boundary. + +2. **Non-seamless indoor render.** Standing inside a cottage/cellar the interior does not + seal: the ceiling isn't capped, the doorway opening shows the blue clear-color instead of + the real outside (no sky / no rain visible through the door), entities/particles bleed + through walls, and at the threshold the view strobes between "indoor (incomplete)" and + "outdoor." acdream's render maintains its **own** cell/visibility system separate from + physics; we believe retail renders inside+outside seamlessly through a single + portal-visibility traversal. + +We have a candidate fix direction ("track the cell through the transition sweep like +retail's `validate_transition` + `change_cell`, drop the static re-derive, add the +`do_not_load_cells` prune, and make the render obey one portal-visibility traversal"), but +we want a **solid, decomp-grounded understanding of how retail ACTUALLY does all of this** +before we commit — patches/guesses in this exact area have failed ~10× historically. + +## 1. Your deliverable + +A comprehensive, decomp-cited markdown report. For **every** non-trivial claim, cite the +retail function **name + address** (from the decomp) or the reference **file:line** you +verified it from. Include short pseudocode where it clarifies control flow. End with a +concrete "Recommended acdream architecture" section (questions D14–16). + +Write your report to the output path given to you (e.g. +`docs/research/2026-06-02-retail-cell-render-study-.md`). + +## 2. Sources to study + +**Primary oracle — the retail decomp (study this first and most):** +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — 1.4 M lines of named pseudo-C. + Grep by `Class::method` (e.g. `CTransition::validate_transition`). +- `docs/research/named-retail/acclient.h` — verbatim retail struct definitions. +- `docs/research/named-retail/symbols.json` — name ↔ address index (grep by name or addr). + +**Reference repos (cross-check at least two per topic; the intersection is usually truth):** +- `references/ACE/` — server-side C# physics port. `Source/ACE.Server/Physics/` has + `Common/` (ObjCell, EnvCell, LandCell, Position), `Animation/` and the transition/ + sphere-path/cell logic. Authoritative C# reading of `find_cell_list`, `change_cell`, + `Transition`, `SpherePath`. +- `references/ACViewer/` — MonoGame client that renders world + dungeons. `Physics/Common/` + (EnvCell, ObjCell, CellArray) and `Render/` (how cells/portals are drawn). +- `references/WorldBuilder/` — the render base acdream extracted from. EnvCell/portal/ + visibility/scenery managers; how it draws interiors + the (flat-stencil) inside/outside + split it uses. +- `references/Chorizite.ACProtocol/`, `references/AC2D/`, `references/holtburger/` — use as + relevant (struct field order, simpler client confirmations). + +**acdream's current code (so your synthesis is actionable, not abstract):** +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — `ResolveCellId` (~:272), `ResolveWithTransition` (~:651, see the two `ResolveCellId(sp.GlobalSphere[0].Origin,…)` calls at ~:909/:928 that discard the swept cell). +- `src/AcDream.Core/Physics/CellTransit.cs` — `FindCellList`/`FindCellSet`/`BuildCellSetAndPickContaining`, `FindTransitCellsSphere`, `AddAllOutsideCells`, `CheckBuildingTransit` (note: NO `do_not_load_cells` prune today). +- `src/AcDream.Core/Physics/TransitionTypes.cs` — `SpherePath` (`CheckCellId`/`CheckPos`, only set at `InitPath`/reset — NOT advanced through the sweep), `Transition` (`FindEnvCollisions`, `CheckOtherCells`). +- `src/AcDream.Core/World/Cells/` — the W1 unified cell graph (`ObjCell`/`EnvCell`/`LandCell`/`CellGraph`/`CellPortal`). +- `src/AcDream.App/Rendering/CellVisibility.cs` + `PortalVisibilityBuilder.cs` + `GameWindow.cs` (terrain/shell/entity draw gates ~:7150–7420). + +## 3. Decomp anchors (verified starting points — confirm and expand; find more yourself) + +Physics / cell tracking: +- `CPhysicsObj::change_cell` @ `0x00513390` (pseudo_c ~:281192) — the leave/enter setter. +- `CPhysicsObj::SetPositionInternal` @ `0x00515330` (~:283399) — reads `arg2->sphere_path.curr_cell`, calls `change_cell` only when it differs. +- `CTransition::validate_transition` @ `0x0050aa70` (~:272547) — advances `sphere_path.curr_cell = check_cell` on an accepted move (~:272608-272619); resets to `curr_cell` on a block/slide (~:272593). +- `CTransition::check_collisions` @ `0x0050aa00` (~:272530); `CTransition::check_other_cells` @ `0x0050ae50` (~:272717) — calls `find_cell_list`. +- `CTransition::transitional_insert` @ `0x0050b6f0` (~:273137) — the sweep stepper. +- `CObjCell::find_cell_list` @ `0x0052b4e0` (~:308742) — builds the cell array, picks the containing cell (`*arg5`), applies the `do_not_load_cells` prune (~:308829-308867). +- `CObjCell::GetVisible` (pseudo_c ~:308209) magnitude dispatch; `CEnvCell::GetVisible`, `CLandCell::GetVisible`, `CLandCell::add_all_outside_cells`, `CObjCell::point_in_cell` (vtable +0x84), `CEnvCell::find_transit_cells`. + +Cell structs (acclient.h): `CObjCell` (:30915), `CEnvCell` (:32072), `CLandCell` (:31886), +`CSortCell` (:31880), `CCellPortal` (:32300), `CBldPortal` (:32094), `CellStruct` (:32275), +plus `SPHEREPATH`, `CELLARRAY`, `Position`. + +Rendering / visibility (verify addresses; these are from prior notes): +- `ConstructView` (~:433750 / :433827), `InitCell` (~:432896), + `CObjCell::find_visible_child_cell` (~:311397). Find the full PVS/PView traversal, + the EnvCell draw path, how exit portals / `seen_outside` feed render, terrain/sky gating. + +## 4. Questions to answer (be comprehensive + cited) + +### A. Cell membership & transitions (physics) +1. How does retail represent and store "the cell I'm in" (`curr_cell`)? When/where is it updated? Trace the full chain: per-step sweep → `find_cell_list` → `validate_transition` advance → `SetPositionInternal`/`change_cell`. +2. Exactly how does `find_cell_list` build the candidate cell array, and how does it pick the single containing cell (`*arg5`)? What is the `do_not_load_cells` prune — when is it set, what does it remove, and what stability does it buy? +3. **Precisely how does retail avoid cell flicker** at a doorway / indoor↔outdoor / room↔room? Is it directed portal-crossing, swept-path containment with accept-on-move, the prune, `point_in_cell` semantics, or a combination? What guarantees a blocked/standing-still step does NOT change the cell? +4. How does a player transition indoor→outdoor (exit) and outdoor→indoor (enter)? Between interior cells? What do `CCellPortal` vs `CBldPortal` do, and how does the exit portal / outdoor landcell get added and chosen? +5. Is the cell ARRAY (for collision) the same mechanism as `curr_cell` (membership), or two? How do they relate within one transition? + +### B. Underground / dungeons +6. How are dungeons represented in the dats and at runtime — EnvCell graph, portals, the absence of terrain? How does this differ from building interiors (cottage/inn) which sit ON a landblock with terrain? +7. How does the player move through a dungeon: cell tracking, dungeon cell loading/streaming, and how the engine knows there is no sky/terrain to draw? +8. Is there an explicit "underground" flag/state (e.g. on `Position`/landblock/cell), or is "underground" simply "current cell is an EnvCell with no outdoor reachability"? Cite the flag/field if it exists. + +### C. Rendering inside and outside (the seamless seal) +9. Trace retail's render visibility: how does it build the visible set in ONE pass (`ConstructView`/`InitCell`/`find_visible_child_cell`/PVS/PView)? What does it output (visible cell list, per-portal clip regions/frustum)? +10. How does retail draw the OUTSIDE seen through a doorway/window from inside (sky, rain, terrain, exterior buildings) so there is **no blue clear-color hole**? How are exit portals / `seen_outside` handled in the render traversal? +11. How does retail seal interiors — cap ceilings, prevent the outdoor world from bleeding in, and clip entities/particles to the visible cells? +12. How does retail decide to draw terrain + sky vs not, as a function of the current cell (indoor / outdoor / underground)? +13. **Is the render cell/visibility the SAME `curr_cell`/cell-graph as physics, or separate?** Trace whether render reads the physics `curr_cell` and traverses the shared cell graph, or maintains its own. (Central to acdream's decision.) + +### D. Synthesis for acdream (be concrete) +14. Given acdream re-derives membership statically per tick (instead of tracking it through the sweep) and renders with a separate cell system, what is the **retail-faithful target architecture** we should port? +15. Specifically: should membership be advanced inside the transition sweep (port `validate_transition`'s `curr_cell` advance + the `do_not_load_cells` prune + drop the static `ResolveCellId`, reading `sphere_path.curr_cell` like `SetPositionInternal`)? Should the render obey the physics `curr_cell` + a single portal-visibility traversal? Justify from the decomp. +16. List the **must-port functions** (with decomp addresses), the integration order, the main risks (esp. anything touching the collision sweep where acdream has a history of bugs), and what conformance tests would prove faithfulness. + +## 5. Method reminders +- Grep the named decomp by `Class::method` FIRST; confirm addresses in `symbols.json`. +- The decomp is ground truth; ACE/ACViewer/WorldBuilder are interpretation aids — when they + disagree, the decomp wins, but note the disagreement. +- Distinguish what you VERIFIED in source from what you INFER. Flag inferences. +- Cite: `function_name @ 0xADDR (pseudo_c:LINE)` or `repo/path/File.cs:LINE`. diff --git a/docs/research/2026-06-02-retail-cell-render-study-codex.md b/docs/research/2026-06-02-retail-cell-render-study-codex.md new file mode 100644 index 0000000..662957d --- /dev/null +++ b/docs/research/2026-06-02-retail-cell-render-study-codex.md @@ -0,0 +1,278 @@ +# Retail cell transitions, dungeons, and seamless inside/outside rendering + +Report date: 2026-06-02 +Author: Codex research pass + +## Executive conclusion + +Retail AC does not treat cell membership as a fresh static classification every frame. It carries `SPHEREPATH.curr_cell` and `SPHEREPATH.check_cell` through the collision sweep, asks `CObjCell::find_cell_list` for collision candidates and a candidate containing cell, accepts that candidate only when the transition step validates, and finally commits `CPhysicsObj::cell` from `sphere_path.curr_cell` in `CPhysicsObj::SetPositionInternal` (`CTransition::check_other_cells` @ 0x0050AE50, pseudo_c:272717-272755; `CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272547-272619; `CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, pseudo_c:283399-283462). + +The current acdream physics path already ports much of the transition internals, but then re-runs `ResolveCellId(...)` after the sweep on both success and partial failure, discarding the swept cell ID (`src/AcDream.Core/Physics/PhysicsEngine.cs:847`, `src/AcDream.Core/Physics/PhysicsEngine.cs:866`). That is the retail mismatch most likely responsible for doorway and room boundary ping-pong. + +Retail render likewise uses the shared cell graph, not an unrelated "camera cell" system. The render loader follows the player/current position through `CellManager::ChangePosition`, reads the current `CObjCell`, and uses `seen_outside` to decide whether landscape, sunlight, and outdoor ambient state remain live (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94601-94682). The portal renderer builds one portal-clipped view traversal with `PView::ConstructView`, `PView::InitCell`, `PView::AddViewToPortals`, and `PView::DrawCells`; outside visible through a doorway is an `outside_view` in the same traversal, not a blue clear-color gap or separate post-process (`PView::ConstructView(CEnvCell*, ushort)` @ 0x005A57B0, pseudo_c:433750-433789; `PView::DrawPortal` @ 0x005A5AB0, pseudo_c:433895-433933; `PView::DrawCells` @ 0x005A4840, pseudo_c:432709-432820). + +The recommended acdream target is therefore: + +1. Make transition sweep state the sole source of membership updates. +2. Port the remaining `CELLARRAY`/`do_not_load_cells` behavior where acdream builds static/cross-cell lists. +3. Stop using render-side AABB camera-cell reclassification as the authority. +4. Replace the split indoor/outdoor render branch with a single PView-style portal traversal rooted in the authoritative current cell, carrying per-cell portal views plus one `OutsideView`. + +## Sources checked + +Primary retail sources: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- `docs/research/named-retail/acclient.h` +- `docs/research/named-retail/symbols.json` + +Reference cross-checks: + +- ACE physics: `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs`, `EnvCell.cs`, `LandCell.cs`, `CellArray.cs`, `Transition.cs`, `PhysicsObj.cs` +- ACViewer physics/render/dat: `references/ACViewer/ACViewer/Physics/...`, `references/ACViewer/ACViewer/Render/R_Landblock.cs`, `R_EnvCell.cs`, `references/ACViewer/ACE/Source/ACE.DatLoader/...` +- WorldBuilder render base: `references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs`, `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`, `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`, `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` +- holtburger dat/world notes: `references/holtburger/crates/holtburger-dat/src/file_type/env_cell.rs`, `references/holtburger/crates/holtburger-world/src/state/liveness.rs`, `references/holtburger/crates/holtburger-common/src/position.rs` +- AC2D and Chorizite were searched for this topic; they did not provide a deeper portal-render model than retail/ACE/ACViewer/WorldBuilder. Chorizite is protocol-focused, not dat/render-focused. + +## A. Cell membership and transitions + +### A1. Retail stores membership as object state plus transition state + +Retail has two relevant layers of cell state: + +- `CPhysicsObj::cell` and `CPhysicsObj::m_position.objcell_id`, the committed object membership (`CPhysicsObj::change_cell` @ 0x00513390, pseudo_c:281192-281215). +- `SPHEREPATH.curr_cell/curr_pos` and `SPHEREPATH.check_cell/check_pos`, the in-flight sweep state. The retail `SPHEREPATH` struct contains `begin_cell`, `curr_cell`, `check_cell`, `hits_interior_cell`, and `cell_array_valid` (`acclient.h:32625-32666`). + +`CPhysicsObj::change_cell` leaves the old cell, enters the new one when non-null, or clears `m_position.objcell_id`, part-array cell ids, and `this->cell` when null (`CPhysicsObj::change_cell` @ 0x00513390, pseudo_c:281192-281215). + +`CPhysicsObj::SetPositionInternal(CTransition const*)` reads `transition.sphere_path.curr_cell`, not a static cell lookup. If the object is already in that cell, it updates position and child/part cell ids. If the cell differs, it calls `change_cell(curr_cell)` (`CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, pseudo_c:283399-283462; ACE mirror `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171-1215`). + +### A2. The per-step sweep chain + +The retail transition sweep is a state machine over `check_pos/check_cell` and `curr_pos/curr_cell`: + +```text +check_collisions: + curr_cell = sphere_path.curr_cell + check_pos = curr_pos + check_cell = curr_cell + FindObjCollisions(...) + +transitional_insert: + if check_cell is null: fail + insert object into check_cell + check_other_cells(check_cell) + +check_other_cells: + find_cell_list(cell_array, &newCell, sphere_path) + test collision in every candidate cell except the already-tested cell + if no collision: + check_cell = newCell + adjust_check_pos(newCell.id) + +validate_transition: + if OK and check_pos moved: + curr_pos = check_pos + curr_cell = check_cell + reset check_pos/check_cell from current + else if blocked/slide but not invalid: + restore check_pos/check_cell to curr_pos/curr_cell + return OK + +SetPositionInternal: + commit object cell from sphere_path.curr_cell +``` + +This pseudocode is from the decomp and ACE cross-checks: `CTransition::check_collisions` seeds check state from current state (`CTransition::check_collisions` @ 0x0050AA00, pseudo_c:272530-272542); `CTransition::transitional_insert` requires `sphere_path.check_cell`, inserts into it, then calls `check_other_cells` (`CTransition::transitional_insert` @ 0x0050B6F0, pseudo_c:273137-273185); `CTransition::check_other_cells` calls `CObjCell::find_cell_list`, tests other cells, then retargets `check_cell` through the containing cell (`CTransition::check_other_cells` @ 0x0050AE50, pseudo_c:272717-272755; ACE mirror `references/ACE/Source/ACE.Server/Physics/Transition.cs:150-185`); `validate_transition` advances `curr_cell = check_cell` only on an accepted move and restores `check_cell = curr_cell` on collision/slide recovery (`CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272547-272619; ACE mirror `references/ACE/Source/ACE.Server/Physics/Transition.cs:984-1091`). + +acdream's `TransitionTypes` largely mirrors this internally: it sets `sp.CurCellId = sp.CheckCellId` on accepted movement and resets check state from current state on blocked/slide recovery (`src/AcDream.Core/Physics/TransitionTypes.cs:3388-3425`). It also retargets `CheckCellId` after a successful other-cell query (`src/AcDream.Core/Physics/TransitionTypes.cs:2061-2075`). The break is after the sweep: `PhysicsEngine.ResolveWithTransition` wraps the final result in another `ResolveCellId(...)` call (`src/AcDream.Core/Physics/PhysicsEngine.cs:847`, `src/AcDream.Core/Physics/PhysicsEngine.cs:866`). + +### A3. How `find_cell_list` builds the cell array + +Retail `CELLARRAY` has `added_outside`, `do_not_load_cells`, `num_cells`, and an array of `CELLINFO` (`acclient.h:31574-31580`). `CObjCell::find_cell_list(Position const*, ...)` clears `num_cells` and `added_outside`, inspects the low word of `Position.objcell_id`, and dispatches to EnvCell or LandCell visible-cell lookup (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308742-308756; `CObjCell::GetVisible` @ 0x0052AD40, pseudo_c:308209-308220). + +If the origin cell is an EnvCell, retail marks `sphere_path.hits_interior_cell = 1` and adds that EnvCell to the cell array (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308764-308766). If the origin cell is a land cell, it calls `CLandCell::add_all_outside_cells` to add the outdoor cells overlapped by the sphere(s) (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308769; `CLandCell::add_all_outside_cells` @ 0x00533630, pseudo_c:317499-317599). + +Then, for every candidate cell in the array, retail calls that cell's `find_transit_cells` virtual (`vtable + 0x80`) to add portal/outdoor/building neighbors (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308775-308785). For EnvCells, `CEnvCell::find_transit_cells` iterates portals: loaded neighbor cells are tested by transforming the sphere into neighbor space and calling `CCellStruct::sphere_intersects_cell`; unloaded neighbor ids can be added as null cells; outside exit portals cause `CLandCell::add_all_outside_cells` to add outdoor land cells (`CEnvCell::find_transit_cells` @ 0x0052C820, pseudo_c:309968-310122; ACE mirror `references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:311-371`). For land cells, `CLandCell::find_transit_cells` calls `add_all_outside_cells` and then the base sorted-cell transit logic (`CLandCell::find_transit_cells` @ 0x00533800, pseudo_c:317603-317608; ACE mirror `references/ACE/Source/ACE.Server/Physics/Common/LandCell.cs:277-280`). + +If the caller asks for a single containing cell, retail iterates the resulting candidate array, transforms the first sphere center into each candidate's local frame, and calls `point_in_cell` (`vtable + 0x84`). The first containing candidate is returned; if it is an EnvCell, `hits_interior_cell` is set and the search stops (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308801-308819; ACE mirror `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:365-383`). + +### A4. `do_not_load_cells` prune + +Retail prunes the cell array when `CELLARRAY.do_not_load_cells != 0` and the origin position is an EnvCell. In that case, `find_cell_list` keeps the current visible cell and cells present in the current visible cell's visible-list, and removes other candidates (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308829-308867). + +This flag is real retail state, not ACE invention: the struct contains `do_not_load_cells` (`acclient.h:31574-31580`), `CPhysicsObj::calc_cross_cells_static` sets it before building a cross-cell list (`CPhysicsObj::calc_cross_cells_static` @ 0x00515160, pseudo_c:283275-283344), and another position-check path sets it when a flag bit is present before `CheckPositionInternal` (`CPhysicsObj` position-check path @ 0x00515BD0 area, pseudo_c:283930-283946). ACE names the same behavior `LoadCells`; `SetStatic()` sets `LoadCells = false`, and `SetDynamic()` sets it true (`references/ACE/Source/ACE.Server/Physics/Common/CellArray.cs:17-29`). + +The stability benefit is bounded: this prune limits speculative unloaded/remote cells for static/cross-cell calculations. It is not the primary doorway anti-flicker mechanism. The primary mechanism is still the accepted sweep state: blocked/slide cases restore `check_pos/check_cell` to `curr_pos/curr_cell`, and only accepted movement advances `curr_cell` (`CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272593-272619). + +acdream currently has no equivalent `do_not_load_cells` prune in `CellTransit.BuildCellSetAndPickContaining`; the indoor branch BFSes the portal graph with a hard `maxIterations = 16`, adds outdoors on any visited exit portal, then point-tests candidates (`src/AcDream.Core/Physics/CellTransit.cs:426-537`). That may over-admit candidates compared with retail when the caller intends a static/no-load cell list. + +### A5. How retail avoids boundary flicker + +Retail avoids doorway and room-boundary flicker by combining four behaviors: + +1. Candidate discovery is sphere/path aware, not just point-grid classification. `find_cell_list` uses sphere overlap via transit-cell queries before choosing a containing cell (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308775-308819). +2. The containing cell is assigned to `check_cell` during `check_other_cells`, before validation (`CTransition::check_other_cells` @ 0x0050AE50, pseudo_c:272717-272755; ACE mirror `references/ACE/Source/ACE.Server/Physics/Transition.cs:179-183`). +3. `validate_transition` commits `check_cell` to `curr_cell` only if the step is accepted and the position actually moved (`CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272608-272619). +4. If the step is blocked, sliding, or adjusted but not invalid, `validate_transition` restores check state from the current state and returns OK; standing still or being pushed back does not independently choose a new cell (`CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272593-272595; ACE mirror `references/ACE/Source/ACE.Server/Physics/Transition.cs:1014-1017`). + +Therefore the answer to "what prevents flicker?" is a combination, but the load-bearing retail invariant is: membership changes only as part of an accepted swept transition, then `SetPositionInternal` commits from `sphere_path.curr_cell` (`CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, pseudo_c:283399-283462). + +acdream violates that invariant by performing a final static re-derive from `sp.GlobalSphere[0].Origin` after the transition (`src/AcDream.Core/Physics/PhysicsEngine.cs:847`, `src/AcDream.Core/Physics/PhysicsEngine.cs:866`). The comments inside `ResolveCellId` already document previous ping-pong behavior caused by point/sphere static checks at indoor boundaries (`src/AcDream.Core/Physics/PhysicsEngine.cs:286-315`). + +### A6. Indoor to outdoor, outdoor to indoor, and interior to interior + +Interior-to-interior movement is handled by `CCellPortal` records on EnvCells. The retail `CEnvCell` struct has `num_portals` and `CCellPortal* portals` (`acclient.h:32072-32090`); `CCellPortal` stores `other_cell_id`, `other_cell_ptr`, `portal`, `portal_side`, `other_portal_id`, and `exact_match` (`acclient.h:32300-32308`). ACViewer's dat loader confirms CellPortal fields: flags, polygon id, other cell id, and other portal id (`references/ACViewer/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs:6-21`). + +Indoor-to-outdoor movement uses EnvCell exit portals. ACE's readable port treats `OtherCellId == ushort.MaxValue` as outside; when the sphere crosses or is near the exit portal plane, it sets `checkOutside = true`, and `LandCell.add_all_outside_cells` adds outdoor cells to the candidate array (`references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:319-331`, `references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:369-370`). Retail decomp shows the same shape in `CEnvCell::find_transit_cells`, including outside expansion through `CLandCell::add_all_outside_cells` (`CEnvCell::find_transit_cells` @ 0x0052C820, pseudo_c:309968-310122). + +Outdoor-to-indoor movement uses building portals (`CBldPortal`) from landblock/building data. Retail `CBldPortal` stores `other_cell_id`, `other_portal_id`, `num_stabs`, and `stab_list` (`acclient.h:32094-32103`). ACViewer's dat loader confirms `CBldPortal.OtherCellId`, `OtherPortalId`, and `StabList` (`references/ACViewer/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs:7-32`). ACE's `EnvCell.check_building_transit` transforms the sphere into the target EnvCell and adds it when the EnvCell BSP says the sphere intersects (`references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:128-144`). acdream has an analogous `CellTransit.CheckBuildingTransit` (`src/AcDream.Core/Physics/CellTransit.cs:299-341`). + +`CCellPortal` and `CBldPortal` therefore serve different roles. `CCellPortal` is an EnvCell-to-EnvCell or EnvCell-to-outside portal inside an EnvCell graph. `CBldPortal` is the building/landblock entry portal that connects an outdoor building instance to an interior EnvCell graph (`acclient.h:32094-32103`, `acclient.h:32300-32308`; WorldBuilder cross-check `references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135`). + +### A7. Cell array vs membership + +The collision cell array and current membership are related but not identical. + +The cell array is a candidate set for collision and containing-cell choice. It can include current cell, adjacent EnvCells, exit outdoor land cells, building-interior cells, and possibly null/unloaded cell ids (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308742-308867; `CEnvCell::find_transit_cells` @ 0x0052C820, pseudo_c:309968-310122). + +Membership is the single `SPHEREPATH.curr_cell` accepted by `validate_transition` and committed by `SetPositionInternal` (`CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272608-272619; `CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, pseudo_c:283399-283462). + +So `find_cell_list` supplies candidates and a proposed `check_cell`; `validate_transition` decides whether that proposal becomes `curr_cell`. + +## B. Underground and dungeons + +### B1. Runtime/dat representation + +Dungeons and building interiors are both EnvCell graphs at runtime. ACViewer's dat loader describes EnvCells as cells whose low word starts at `0x0100`, with surfaces, environment id, cell structure, position, cell portals, visible cells, static objects, and flags (`references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:7-63`). Holtburger's dat parser reads the same shape: `EnvCell` has `flags`, `surfaces`, `environment_id`, `cell_structure`, `position`, `portals`, `visible_cells`, `static_objects`, and optional restriction object (`references/holtburger/crates/holtburger-dat/src/file_type/env_cell.rs:8-84`). + +Building interiors differ from pure dungeons because the landblock still has outdoor terrain and building data. `LandblockInfo` records the number of EnvCells and building records (`references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs:7-58`). WorldBuilder's `PortalService` starts from LandBlockInfo building portals and BFSes EnvCells to build portal groups (`references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135`). + +ACE identifies a pure dungeon landblock by all terrain heights being zero, at least one EnvCell, and no buildings (`references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277`). It also has `HasDungeon` for landblocks that contain EnvCells without necessarily being fully terrainless (`references/ACE/Source/ACE.Server/Entity/Landblock.cs:1282-1302`). ACViewer mirrors this distinction in rendering: it skips outdoor building/static/scenery generation for `landblock.IsDungeon`, but builds EnvCells for `landblock.HasDungeon` or when `OutdoorEnvCells` is enabled (`references/ACViewer/ACViewer/Render/R_Landblock.cs:35-55`, `references/ACViewer/ACViewer/Render/R_Landblock.cs:83-103`). + +### B2. Movement through dungeons + +Movement through a dungeon uses the same EnvCell transition machinery as any interior. Current low word `>= 0x0100` dispatches through EnvCell visible/transit logic, `CEnvCell::find_transit_cells` walks portals, and `validate_transition` commits accepted `curr_cell` (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308749-308769; `CEnvCell::find_transit_cells` @ 0x0052C820, pseudo_c:309968-310122; `CTransition::validate_transition` @ 0x0050AA70, pseudo_c:272608-272619). + +Retail does not need a separate movement flag for "dungeon" in this path. The low-word cell range and EnvCell portal graph are sufficient for cell tracking. Holtburger's common position helper reflects the same client-side convention: low word `>= 0x0100` means indoors, while outdoor cells are the 64 land cells (`references/holtburger/crates/holtburger-common/src/position.rs:79-89`, `references/holtburger/crates/holtburger-common/src/position.rs:129-143`). + +### B3. No explicit "underground" flag found + +I did not find a retail `Position` or `CObjCell` field that directly means "underground". The explicit flag that matters for render/load policy is `CObjCell.seen_outside` (`acclient.h:30915-30929`). ACViewer/ACE names the EnvCell flag bit `SeenOutside = 0x1` (`references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:6-10`), and the EnvCell dat loader exposes `SeenOutside => Flags.HasFlag(EnvCellFlags.SeenOutside)` (`references/ACViewer/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:22-34`). + +Inference from sources: "underground" for render purposes is best modeled as current cell is an EnvCell with no outside visibility/reachability and, for a pure dungeon landblock, no meaningful outdoor terrain to draw. This inference is supported by retail `CellManager::ChangePosition`, which keeps or releases landscape based on `seen_outside` and current/outdoor state rather than an underground flag (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94649-94682), and by ACE's `IsDungeon` heuristic (`references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277`). + +## C. Rendering inside and outside + +### C1. Retail render is a portal-view traversal + +Retail `PView` stores an `outside_view`, an outdoor portal list, a cell draw list, a todo list, and an `LScape*` (`acclient.h:45934-45944`). `CEnvCell` stores per-cell portal-view arrays: `num_view` and `DArray portal_view` (`acclient.h:32072-32090`). `portal_view_type` contains portal clip data, `view_count`, `cell_view_done`, `view_timestamp`, and `update_count` (`acclient.h:32346-32355`). + +The render traversal starts with `PView::ConstructView(CEnvCell*, ushort)`: it clears `outside_view.view_count`, increments `master_timestamp`, clears todo/draw lists, initializes the seed EnvCell, inserts it into the todo list, then repeatedly pops cells, adds them to the draw list, clips portals, and propagates views to neighbor portals (`PView::ConstructView(CEnvCell*, ushort)` @ 0x005A57B0, pseudo_c:433750-433789). + +`PView::InitCell` initializes the current EnvCell's `portal_view_type`, decides which portals are visible for the current view slice, calculates portal clip metadata, calls `Render::set_view`, and sets `update_count = view_count` (`PView::InitCell` @ 0x005A4B70, pseudo_c:432896-433045). `PView::AddToCell` processes only new view slices from `update_count` to `view_count` (`PView::AddToCell` @ 0x005A4D90, pseudo_c:433050-433085). `PView::AddViewToPortals` initializes or appends to neighbor cells' portal views, queues neighbors, and calls `SetOtherSeen` for matching portal ids (`PView::AddViewToPortals` @ 0x005A52D0, pseudo_c:433446-433520). + +This is important for issue #102: retail does not use a fixed small "max reprocess per cell" cap. It uses per-cell `update_count` watermarks and a todo list until the propagated view slices converge (`PView::InitCell` @ 0x005A4B70, pseudo_c:433043; `PView::AddToCell` @ 0x005A4D90, pseudo_c:433056-433080; `PView::AddViewToPortals` @ 0x005A52D0, pseudo_c:433494-433503). acdream currently has `MaxReprocessPerCell = 4` in `PortalVisibilityBuilder` (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:41`, `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:110`). + +### C2. Outside through a doorway/window + +Retail treats outside seen through an interior portal as part of the same PView. `PView::DrawPortal` selects a `CBldPortal` from `outdoor_portal_list`, adds the portal's stab views, calls `PView::ConstructView(CBldPortal*, CPolygon*, ...)`, then draws cells if construction succeeds (`PView::DrawPortal` @ 0x005A5AB0, pseudo_c:433895-433933). + +`PView::ConstructView(CBldPortal*, CPolygon*, ...)` classifies the viewer against the portal plane and portal side, clips through the portal polygon, gets the target EnvCell by `other_cell_id`, copies the view into that cell's portal view, optionally draws the portal polygon, and recursively constructs the EnvCell view (`PView::ConstructView(CBldPortal*, CPolygon*, int, int)` @ 0x005A59A0, pseudo_c:433827-433891). + +`PView::DrawCells` checks `outside_view.view_count`; when nonzero, it enables sunlight, draws `LScape`, flushes alpha, then loops the cell draw list and draws EnvCell contents using the per-cell portal views (`PView::DrawCells` @ 0x005A4840, pseudo_c:432715-432807). This is the decomp evidence that a doorway to outside should render landscape/sky/rain/exterior content through the clipped outside view. It should not show clear color. + +acdream has an `OutsideView` concept in `PortalVisibilityBuilder`, but it is currently owned by a separate render-side cell system and a split indoor branch (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:6-19`, `src/AcDream.App/Rendering/GameWindow.cs:11021-11235`). The concept is directionally right; the authority and traversal convergence differ from retail. + +### C3. Interior sealing and clipping + +Retail interior sealing is primarily geometry plus portal clipping: + +- EnvCells render their own surfaces, environment/cell structure, static objects, and lights (`CEnvCell` struct fields in `acclient.h:32072-32090`; ACViewer `R_EnvCell` draws EnvCell environment and static objects from EnvCell transforms/textures, `references/ACViewer/ACViewer/Render/R_EnvCell.cs:24-82`). +- The visible cell list is portal-clipped by `PView`; each EnvCell draw uses its current portal views (`PView::DrawCells` @ 0x005A4840, pseudo_c:432737-432807). +- `CObjCell::find_visible_child_cell` searches current EnvCell/portal-neighbor/visible cells by `point_in_cell`, so child/entity visibility is also cell-graph based rather than global AABB visibility (`CEnvCell::find_visible_child_cell` @ 0x0052DC50, pseudo_c:311397-311462). + +Inference from sources: retail prevents wall/entity/particle bleed by drawing only the portal-visible EnvCells/entities under the current PView clips and by treating outside as a clipped `outside_view`. It is not relying on a broad "render all indoor then stencil terrain" split. + +WorldBuilder's current/reference path is coarser. It builds building portal groups from LandBlockInfo and EnvCell BFS (`references/WorldBuilder/WorldBuilder.Shared/Services/PortalService.cs:45-135`), then `VisibilityManager.RenderInsideOut` marks all portals of current/frustum-visible building groups in stencil, punches depth, renders full EnvCells of current buildings, and renders terrain/scenery through the stencil (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-154`). That is useful as a Silk.NET implementation base, but it is not the retail PView algorithm. + +### C4. Terrain and sky draw policy + +Retail exposes a simple outside test for the player position. `SmartBox::is_player_outside` reads the player object's `m_position.objcell_id` low word and returns true for outdoor cell ids, i.e. low word below the EnvCell range (`SmartBox::is_player_outside` @ 0x00451E80, pseudo_c:90996-91007). `Position::get_outside_cell_id` uses `LandDefs::adjust_to_outside` to derive the outdoor cell id from the position's origin and returns that adjusted outside id when applicable (`Position::get_outside_cell_id` @ 0x004527B0, pseudo_c:91552-91575). + +`CellManager::ChangePosition` is the main render/load bridge. When moving to a new current cell, it prefetches cells, gets the `CObjCell`, and if the cell is outside or `seen_outside` or `keep_lscape_loaded`, it updates the landscape load point. If not, it releases all landscape. Later, if the current cell is outside or `seen_outside`, it copies sunlight and landscape ambient light; otherwise it sets a flat indoor ambient value (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94601-94682). + +So retail's terrain/sky policy is not "indoor means no outside ever." It is: + +- Outdoor current cell: landscape/sunlight/ambient are active. +- EnvCell with `seen_outside`: landscape/sunlight/outdoor ambient remain active and can be drawn through portal views. +- EnvCell without `seen_outside`: landscape is released and indoor ambient is used. + +This maps to cottages/cellars/dungeons better than acdream's current split. acdream currently computes a separate render camera cell, gates a kill-switched indoor branch on `ACDREAM_A8_INDOOR_BRANCH`, renders sky when `!cameraInsideCell || cameraInsideBuilding`, and skips terrain when `cameraInsideBuilding` so `RenderInsideOutAcdream` can stencil-gate terrain (`src/AcDream.App/Rendering/GameWindow.cs:7325-7356`, `src/AcDream.App/Rendering/GameWindow.cs:7539-7597`, `src/AcDream.App/Rendering/GameWindow.cs:7634-7645`). + +### C5. Is render cell visibility the same graph as physics? + +Verified: retail render and physics both operate on `CObjCell`/`CEnvCell`/`CLandCell` graph objects. Physics uses `CObjCell::GetVisible`, `CEnvCell::find_transit_cells`, `CLandCell::add_all_outside_cells`, and `point_in_cell` for transitions (`CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308742-308867). Render uses `CEnvCell` portal arrays, `CObjCell.seen_outside`, `CEnvCell::find_visible_child_cell`, and `PView` over EnvCells (`acclient.h:30915-30929`, `acclient.h:32072-32090`, `CEnvCell::find_visible_child_cell` @ 0x0052DC50, pseudo_c:311397-311462; `PView::ConstructView(CEnvCell*, ushort)` @ 0x005A57B0, pseudo_c:433750-433789). + +The render loader is explicitly fed by the player's/current position through `CellManager::ChangePosition` (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94601-94682). That is the bridge acdream should mirror: render should be rooted in authoritative physics/current cell, even if camera collision/follow offset needs a child-cell lookup for view origin. + +holtburger independently notes the same target for client-side visibility: its world-state liveness code currently uses a conservative approximation, with a TODO to replace it by detecting the player's EnvCell, reading `SeenOutside`, unioning current EnvCell visible cells, and merging outdoor visibility only for SeenOutside EnvCells (`references/holtburger/crates/holtburger-world/src/state/liveness.rs:135-146`). + +## D. Recommended acdream architecture + +### D14. Retail-faithful target + +Use one cell graph and one membership authority: + +- Core physics owns current membership: `PhysicsBody.CellId`/equivalent should be updated from transition `SpherePath.CurCellId`, not from a post-sweep `ResolveCellId`. +- `CellTransit` remains the port of retail candidate discovery, but its output should be consumed as `check_cell` inside the transition, not as a standalone static classifier at the end of every tick. +- Render receives the authoritative current cell id plus camera position. If the camera is offset from the player, render may call a retail-style visible child lookup (`CEnvCell::find_visible_child_cell`) within the same graph, but it should not run an independent AABB `FindCameraCell` as the source of truth. +- Render builds one PView-style frame: per-cell portal views, a todo list, a draw list, and an `OutsideView`. It uses `seen_outside` to decide whether outside landscape/light is active. + +This aligns with the roadmap's Phase U direction: replace the abandoned two-pipe indoor/outdoor split with a unified retail-faithful render pipeline. + +### D15. Specific decisions + +Yes: membership should be advanced inside the transition sweep. acdream should remove the final static `ResolveCellId` calls from `ResolveWithTransition` and return `sp.CurCellId`/accepted transition state after `FindTransitionalPosition` and `ValidateTransition`, matching retail `SetPositionInternal` (`CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, pseudo_c:283399-283462; current mismatch `src/AcDream.Core/Physics/PhysicsEngine.cs:847`, `src/AcDream.Core/Physics/PhysicsEngine.cs:866`). + +Yes: port the `do_not_load_cells` prune, but treat it as a secondary correctness/stability fix. Add explicit `CellArray` semantics to `CellTransit` or equivalent: `AddedOutside`, `DoNotLoadCells`, candidate ids and loaded cell pointers. Then apply the retail prune when building no-load/static lists from an EnvCell origin (`CELLARRAY` struct `acclient.h:31574-31580`; prune in `CObjCell::find_cell_list` @ 0x0052B4E0, pseudo_c:308829-308867; ACE `SetStatic`/`SetDynamic` `references/ACE/Source/ACE.Server/Physics/Common/CellArray.cs:17-29`). + +Yes: render should obey physics current cell and a single portal traversal. acdream's current `CellVisibility.FindCameraCell` does AABB point checks with grace frames (`src/AcDream.App/Rendering/CellVisibility.cs:301-380`), and `GameWindow` gates a separate indoor branch with `ACDREAM_A8_INDOOR_BRANCH` (`src/AcDream.App/Rendering/GameWindow.cs:7325-7356`). Retail instead passes current position/cell into `CellManager::ChangePosition`, uses `seen_outside`, and traverses PView (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94601-94682; `PView::ConstructView(CEnvCell*, ushort)` @ 0x005A57B0, pseudo_c:433750-433789). + +### D16. Must-port functions and integration order + +1. **Physics result commit** + - Must-port/align: `CTransition::validate_transition` @ 0x0050AA70, `CPhysicsObj::SetPositionInternal(CTransition const*)` @ 0x00515330, `CPhysicsObj::change_cell` @ 0x00513390. + - Work: return accepted `sp.CurCellId`/`sp.CheckCellId` according to retail state, remove post-sweep static `ResolveCellId` from success and partial-failure paths. + - Tests: doorway stationary jitter test, room-to-room threshold test, cellar ramp top test, blocked-wall no-cell-change test. Assert cell id changes only after accepted movement. + +2. **Cell array parity** + - Must-port/align: `CObjCell::find_cell_list` @ 0x0052B4E0, `CEnvCell::find_transit_cells` @ 0x0052C820, `CLandCell::add_all_outside_cells` @ 0x00533630, `CELLARRAY::add_cell` @ 0x006B4FF0, `CELLARRAY::remove_cell` @ 0x006B4E80. + - Work: replace hard-cap BFS/static hash-set behavior with a closer `CELLARRAY` model; include loaded/null cell entries, `added_outside`, `do_not_load_cells`, and retail prune. + - Tests: golden candidate arrays around a cottage doorway, cellar exit, interior room portal, and outdoor-to-building entry. Include no-load prune cases from EnvCell origin. + +3. **Render cell root** + - Must-port/align: `CellManager::ChangePosition` @ 0x004559B0, `SmartBox::is_player_outside` @ 0x00451E80, `Position::get_outside_cell_id` @ 0x004527B0, `CEnvCell::find_visible_child_cell` @ 0x0052DC50. + - Work: render frame root uses physics current cell id; `seen_outside` controls landscape/light availability. Camera child lookup should be graph/BSP based, not AABB-only. + - Tests: inside EnvCell with `seen_outside` keeps outdoor light/landscape; sealed dungeon EnvCell suppresses landscape; camera near doorway does not strobe render branch. + +4. **PView traversal** + - Must-port/align: `PView::ConstructView(CEnvCell*, ushort)` @ 0x005A57B0, `PView::InitCell` @ 0x005A4B70, `PView::AddToCell` @ 0x005A4D90, `PView::AddViewToPortals` @ 0x005A52D0, `PView::DrawPortal` @ 0x005A5AB0, `PView::DrawCells` @ 0x005A4840. + - Work: replace `MaxReprocessPerCell` cap with `portal_view_type.update_count`-style propagation. Produce `cell_draw_list` and `outside_view` from one traversal. Draw landscape through `outside_view`, then EnvCells through per-cell views. + - Tests: portal graph convergence test where a cell receives multiple clipped slices; doorway outside-view non-empty test; sealed cellar no-outside-view test; no clear-color pixel through door in visual QA. + +5. **Render object/entity clipping** + - Must-port/align: `CEnvCell::find_visible_child_cell` @ 0x0052DC50 and PView draw-list semantics. + - Work: cull or stencil entities/particles by visible cell/portal view, not just world frustum. + - Tests: entity behind wall in adjacent EnvCell is not visible; entity visible through doorway remains visible; particles do not bleed through sealed walls. + +### Main risks + +- Removing post-sweep `ResolveCellId` will expose any missing `CheckCellId` updates inside acdream's transition port. Before deleting it outright, add diagnostics proving `sp.CheckCellId`/`sp.CurCellId` are updated by `CheckOtherCells` for the known doorway, room, and cellar cases (`src/AcDream.Core/Physics/TransitionTypes.cs:2061-2075`, `src/AcDream.Core/Physics/TransitionTypes.cs:3388-3425`). +- acdream's `CellTransit.FindTransitCellsSphere` currently marks `exitOutside = true` for any exit portal encountered in BFS (`src/AcDream.Core/Physics/CellTransit.cs:95-123`). Retail/ACE gate outside expansion on portal-plane/sphere tests in the EnvCell transit path (`CEnvCell::find_transit_cells` @ 0x0052C820, pseudo_c:309968-310122; ACE `references/ACE/Source/ACE.Server/Physics/Common/EnvCell.cs:319-331`). Revisit this before relying on the transition cell as final truth. +- Render work can regress everything if it tries to preserve the old branch split. Implement the PView traversal as a frame product first and compare it against current `PortalVisibilityBuilder` outputs before changing draw order. +- Dungeons need separate visual QA from cottages: pure dungeon landblocks should not draw outdoor terrain, while `seen_outside` building/cellar EnvCells may still need clipped outdoor draw (`CellManager::ChangePosition` @ 0x004559B0, pseudo_c:94649-94682; ACE dungeon heuristic `references/ACE/Source/ACE.Server/Entity/Landblock.cs:1266-1277`). + +## Final answer to the decision + +The candidate fix direction is correct, with one clarification: `do_not_load_cells` is necessary for parity but not sufficient by itself. The load-bearing retail behavior is transition-owned membership. acdream should stop reclassifying the final static point after the sweep, commit the swept cell like `SetPositionInternal`, and make render consume that same current cell through a PView-style portal traversal with `seen_outside`/`OutsideView`. diff --git a/docs/research/2026-06-02-retail-cell-render-study-opus48-a.md b/docs/research/2026-06-02-retail-cell-render-study-opus48-a.md new file mode 100644 index 0000000..ef815e2 --- /dev/null +++ b/docs/research/2026-06-02-retail-cell-render-study-opus48-a.md @@ -0,0 +1,1331 @@ +# Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering + +> **Independent decomp study (model: Opus 4.8, variant A). 2026-06-02.** +> Primary oracle: the named retail decomp at `docs/research/named-retail/` +> (`acclient_2013_pseudo_c.txt`, `acclient.h`, `symbols.json`). Cross-checked +> against `references/ACE` (C# physics port), `references/ACViewer` (MonoGame +> viewer), `references/WorldBuilder`. Citations are `function @ 0xADDR +> (pseudo_c:LINE)` for decomp and `repo/path:LINE` for references. Each +> non-trivial claim is tagged **[VERIFIED]** (read in source) or **[INFERRED]**. + +--- + +## TL;DR (headline findings) + +1. **Retail never re-derives the cell from the final XYZ.** It *tracks* the + current cell (`sphere_path.curr_cell`) **through the collision sweep**. The + advance happens in `CTransition::validate_transition` on an accepted + sub-step (`curr_cell = check_cell`), and a **blocked/standing-still + sub-step explicitly resets `check_cell` back to `curr_cell`** — so a + push-back can never change which cell you are in. At the end of the tick, + `CPhysicsObj::SetPositionInternal(CTransition*)` reads + `transition.sphere_path.curr_cell` and calls `change_cell` **only when it + differs** from the held cell. This is the structural cure for the flicker. + +2. **`find_cell_list` builds a candidate cell array (containment + portal + neighbours), picks the *single* containing cell — interior cells win and + short-circuit — and then applies the `do_not_load_cells` prune** which + removes every candidate that is neither the current cell nor in the current + interior cell's static visibility list (`stab_list`). The prune is what + buys stability for interior membership: it stops far/irrelevant cells from + ever becoming the answer. + +3. **Underground/dungeons are NOT a special mode.** There is no "underground" + flag in the retail client physics or render path. A dungeon is simply a + landblock whose terrain heights are all zero, which has ≥1 EnvCell and no + buildings — so you are always inside an `EnvCell` whose `seen_outside == 0`, + and the renderer therefore never draws terrain or sky. "Inside a building" + differs only in that the EnvCell sits on a real terrain landblock and + (often) has `seen_outside == 1`, so the landscape is still drawn through + the door. + +4. **Retail renders inside+outside in ONE portal-visibility traversal + (`PView`).** `SmartBox::RenderNormalMode` makes a single decision per frame + from `viewer_cell` (an outdoor landcell → draw the landscape; an EnvCell → + `DrawInside`). `PView::ConstructView` does a breadth-first portal walk that + produces an ordered visible cell list (`cell_draw_list`) plus per-portal + screen clip regions. When a portal's `other_cell_id == 0xffffffff` (an exit + portal to the outdoors) it pulls the **landscape into the same traversal, + clipped to the doorway**, so there is no blue clear-color hole; sky/rain/ + terrain show through. The depth clear is conditional, not an unconditional + blue fill. + +5. **Render and physics use the SAME cell graph and the SAME `objcell_id`.** + The render-side `CellManager::ChangePosition` resolves its `curr_cell` via + `CObjCell::Get(position.objcell_id)` — the very same id physics maintains. + `seen_outside` is a per-cell dat flag (`EnvCellFlags.SeenOutside = 0x1`). + Both the physics cell array and the render PVS traverse cells via the same + `CEnvCell::portals` / `stab_list` / `GetVisible` machinery (just different + BSP trees inside the same `CCellStruct`: `physics_bsp`/`cell_bsp` vs + `drawing_bsp`). + +6. **acdream's bug is localized and the fix surface already exists.** + acdream's `SpherePath` already tracks `CurCellId`/`CheckCellId` and its + `ValidateTransition` already advances/resets them correctly + (`TransitionTypes.cs:3398-3434`). The *only* defect is at the engine + output: `ResolveWithTransition` (`PhysicsEngine.cs:909` and `:928`) throws + away `sp.CurCellId` and **re-derives the cell statically** via + `ResolveCellId(sp.GlobalSphere[0].Origin, …)`. Plus there is no + `do_not_load_cells` prune in `CellTransit`, and the render maintains a + *separate* cell system (`CellVisibility.FindCameraCell` with a 3-frame + grace-frame band-aid). The unified W1 `CellGraph` (`CurrCell` + + `GetVisible`) is the seam to land all three fixes on. + +--- + +# A. Cell membership & transitions (physics) + +## A0. The data model: `SPHEREPATH` and `CObjCell` + +The single source of truth for "the cell I'm in" during a move is +`SPHEREPATH::curr_cell`. The struct (verbatim retail header) is: + +`acclient.h:32625` — `struct SPHEREPATH` **[VERIFIED]**: +```c +struct SPHEREPATH { + ... + CObjCell *begin_cell; // where the sweep starts (== object's current cell) + Position *begin_pos; + Position *end_pos; // requested destination + CObjCell *curr_cell; // ACCEPTED cell — the committed membership answer + Position curr_pos; // ACCEPTED position + ... + CObjCell *check_cell; // CANDIDATE cell being tested this sub-step + Position check_pos; // CANDIDATE position + SPHEREPATH::InsertType insert_type; // TRANSITION_INSERT / PLACEMENT_INSERT + ... + CObjCell *backup_cell; // saved for restore (step-up/walkable probes) + Position backup_check_pos; + int hits_interior_cell; // set when an interior cell was added/contained + int cell_array_valid; + ... +}; +``` + +The cell objects themselves (`acclient.h:30915` `CObjCell`, `:32072` +`CEnvCell`, `:31886` `CLandCell`) **[VERIFIED]**: + +```c +struct CObjCell : SerializeUsingPackDBObj, CPartCell { + LandDefs::WaterType water_type; + Position pos; // cell-to-world frame + ... + unsigned int num_shadow_objects; + DArray shadow_object_list; // collision objects registered in this cell + unsigned int restriction_obj; + ... + unsigned int num_stabs; + unsigned int *stab_list; // STATIC visibility set: cell ids this cell can see + int seen_outside; // boolean: this interior can see the outdoors + ... + CLandBlock *myLandBlock_; +}; + +struct CEnvCell : CObjCell { + ... + CCellStruct *structure; // geometry: vertices, polys, 3 BSP trees + unsigned int num_portals; + CCellPortal *portals; // portal graph edges + unsigned int num_static_objects; + ... + unsigned int num_view; + DArray portal_view; // RENDER per-portal clip state +}; + +struct CLandCell : CSortCell { // CSortCell carries `building` (CBuildingObj*) + CPolygon **polygons; + BoundingType in_view; +}; +``` + +Key takeaways for membership: +- **`curr_cell` ≠ "the cell array".** `curr_cell` is *one* cell (membership). + `cell_array` is the multi-cell set the collision sweep queries (see A5). +- **`stab_list`** is a precomputed per-cell visibility list (the "stabs"). + It is used by both the physics prune (A2) and the render PVS (C9). +- **`seen_outside`** is a per-cell boolean meaning "this interior cell has an + exterior portal / can see outdoors." (Confirmed as dat flag + `EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7`). **[VERIFIED]** + +## A1. The full membership chain (per tick) + +The per-tick movement flows: +`UpdateObjectInternal → transition → find_valid_position → find_transitional_position → (loop) transitional_insert + validate_transition → SetPositionInternal → change_cell`. + +### Step 1 — seed `curr_cell` from the object's current cell + +`CPhysicsObj::transition @ 0x00512dc0 (pseudo_c:280904)` **[VERIFIED]** builds a +fresh `CTransition`, then at `:280939`: +```c +CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/); +``` +`init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359)` **[VERIFIED]** +seeds: +```c +this->begin_cell = arg2; // == CPhysicsObj.cell +... +this->curr_pos.objcell_id = arg3->objcell_id; // begin pos +this->curr_cell = arg2; // curr_cell STARTS as the held cell (:274370) +this->insert_type = TRANSITION_INSERT; +``` +So the sweep *begins* anchored to the cell you are already in. + +### Step 2 — the sub-step loop advances `check_pos`, then validates + +`CTransition::find_transitional_position @ 0x0050bdf0 (pseudo_c:273613)` +**[VERIFIED]** is the driver. It computes a step count (`calc_num_steps`), then +per sub-step (for `TRANSITION_INSERT`, `pseudo_c:273736-273755`): +```c +// advance the CANDIDATE position by the per-step offset +this->sphere_path.check_pos.frame.origin += global_offset; // :273739-273741 +SPHEREPATH::cache_global_sphere(&sphere_path, &global_offset); +var_44 = CTransition::validate_transition(this, + CTransition::transitional_insert(this, 3), &var_40); // :273743 +... +ebx += 1; +if (ebx >= var_48) goto done; +continue; +``` +At the very end (when all sub-steps are consumed, `pseudo_c:273648-273651`): +```c +SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); +sphere_path.cell_array_valid = 1; +sphere_path.hits_interior_cell = 0; +CObjCell::find_cell_list(&this->cell_array, nullptr, &this->sphere_path); // rebuild final cell array +``` +**Critical**: the final `find_cell_list` rebuilds the *collision* `cell_array` +around the final position, but the **membership answer is already in +`curr_cell`** (advanced by `validate_transition` during the loop). The final +`find_cell_list` is passed `arg5 = nullptr` (no `*currCell` out-param), so it +does NOT re-pick membership. + +### Step 3 — `transitional_insert`: one sub-step of collision + cell discovery + +`CTransition::transitional_insert @ 0x0050b6f0 (pseudo_c:273137)` **[VERIFIED]** +is the inner stepper. Per iteration: +```c +edi = CTransition::insert_into_cell(this, sphere_path.check_cell, eax_2); // collide in check_cell +switch (edi) { + case OK_TS: + edi = CTransition::check_other_cells(this, sphere_path.check_cell); // discover/cross cells + ... +} +``` +`insert_into_cell @ 0x00509e70 (pseudo_c:271991)` **[VERIFIED]** simply calls +`arg2->vtable->find_collisions(this)` (the cell's own collision query). On +`OK_TS`, `check_other_cells` runs. + +### Step 4 — `check_other_cells`: cross into the right cell mid-sweep + +`CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717)` **[VERIFIED]**: +```c +sphere_path.cell_array_valid = 1; +sphere_path.hits_interior_cell = 0; +CObjCell::find_cell_list(&this->cell_array, &var_4c /*out: containing cell*/, &sphere_path); +for (i : cell_array) { // collide against every other cell + cell = cell_array.cells[i].cell; + if (cell && cell != arg2) + result = cell->vtable[+0x88](this); // find_collisions + if (result is COLLIDED/ADJUSTED/SLID/CP-clear) handle/return; +} +check_cell = var_4c; // RETARGET candidate cell to the containing cell +if (check_cell != 0) { + SPHEREPATH::adjust_check_pos(&sphere_path, check_cell->id); // rebase check_pos id to new cell + return result; +} +// --- no containing cell found: we have left the indoor graph --- +if (sphere_path.step_down) return COLLIDED_TS; +objcell_id = sphere_path.check_pos.objcell_id; +if (objcell_id < 0x100) { /* already outside */ } +else LandDefs::adjust_to_outside(objcell_id, &frame); // CONVERT interior id -> outdoor landcell id +if (objcell_id == 0) return COLLIDED_TS; +SPHEREPATH::adjust_check_pos(&sphere_path, objcell_id); +sphere_path.check_cell = nullptr; // will be resolved next find_cell_list +``` +This is the **indoor→outdoor exit**: when the foot sphere no longer lands in +any portal-connected interior cell, `adjust_to_outside` +(`LandDefs::adjust_to_outside @ 0x005a9bc0 (pseudo_c:438719)` **[VERIFIED]**) +maps the interior cell id to the surrounding landblock's outdoor cell id, and +`check_cell` becomes the landcell on the next pass. + +### Step 5 — `validate_transition`: ADVANCE on accept, RESET on block (the anti-flicker) + +`CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547)` **[VERIFIED]**. +The decisive control flow: + +```c +if (transitionState != OK || check_pos == curr_pos) // blocked OR no movement +{ + if (transitionState != OK) { + if (transitionState != INVALID) { // a real block/slide/adjust + ... (contact-plane / last-known-plane handling) ... + SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // :272593 + // ^^^ RESET candidate back to curr_pos / curr_cell — cell does NOT change + CTransition::build_cell_array(this, nullptr); + transitionState = OK_TS; + } + } else { + // check_pos == curr_pos: accept-no-move => label_50aba9 advances (no-op) + } +} +else // OK and we moved +{ +label_50aba9: // :272608 + check_cell = sphere_path.check_cell; + sphere_path.curr_pos.objcell_id = sphere_path.check_pos.objcell_id; + sphere_path.curr_cell = check_cell; // :272612 ADVANCE membership + SPHEREPATH::cache_global_curr_center(&sphere_path); + // re-seed check_* = curr_* for the next sub-step + sphere_path.check_pos.objcell_id = sphere_path.curr_pos.objcell_id; + sphere_path.check_cell = sphere_path.curr_cell; // :272617 + sphere_path.cell_array_valid = 0; +} +``` + +**The anti-flicker guarantee, stated precisely:** +- A sub-step that **moves and is accepted** (`OK_TS` && `check_pos != curr_pos`) + advances `curr_cell = check_cell` (the cell that the sweep crossed into). +- A sub-step that is **blocked/slid/adjusted** (not `OK_TS`) calls + `set_check_pos(curr_pos, curr_cell)` — it **rolls the candidate cell BACK to + the already-accepted cell**. Membership cannot change on a block. +- A sub-step that is **OK but does not move** (`check_pos == curr_pos`) takes + the no-op path; membership stays. + +So push-back jitter — which is exactly a "block" producing a small reversal — +never changes `curr_cell`. The cell only changes when the swept path +*genuinely crosses* into a new cell and that crossing is accepted. + +**ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Transition.cs:984` +`ValidateTransition`: +```csharp +if (transitionState != TransitionState.OK || SpherePath.CheckPos.Equals(SpherePath.CurPos)) { + ... + SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); // line 1014 (block path: reset) + ... +} else + SetCurrentCheckPos(); // line 1024 (accept path) +``` +`SetCurrentCheckPos()` (`Transition.cs:1084`): +```csharp +SpherePath.CurPos = new Position(SpherePath.CheckPos); +SpherePath.CurCell = SpherePath.CheckCell; // ADVANCE (line 1087) +SpherePath.CacheGlobalCurrCenter(); +SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); +``` +ACE matches the decomp exactly. + +### Step 6 — commit: `SetPositionInternal` reads `curr_cell`, calls `change_cell` only on change + +`CPhysicsObj::UpdateObjectInternal @ 0x005156b0 (pseudo_c:283611)` **[VERIFIED]**: +```c +class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); // :283673 +if (eax_10 == 0) { ... no move ... } +else { + Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); // velocity + ... + CPhysicsObj::SetPositionInternal(this, eax_10); // :283696 +} +``` + +`CPhysicsObj::SetPositionInternal(CTransition*) @ 0x00515330 (pseudo_c:283399)` +**[VERIFIED]** — the membership commit: +```c +class CObjCell* curr_cell = arg2->sphere_path.curr_cell; // :283403 READ the swept cell +if (curr_cell == 0) { prepare_to_leave_visibility(); store_position(...); GotoLostCell(...); } +else { + if (this->cell == curr_cell) { // :283414 SAME cell + this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id; // just update id + // (also propagate id to part_array + children) + } else { + CPhysicsObj::change_cell(this, curr_cell); // :283456 CHANGE only on differ + } + CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame); + ... +} +``` + +`CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192)` **[VERIFIED]**: +```c +if (this->cell != 0) CPhysicsObj::leave_cell(this, 1); // remove from old cell's shadow list +if (arg2 != 0) { CPhysicsObj::enter_cell(this, arg2); return; } // add to new cell's shadow list +this->m_position.objcell_id = 0; this->cell = nullptr; +``` + +**ACE cross-check [VERIFIED]** — `PhysicsObj.cs:1171` `SetPositionInternal(Transition)`: +```csharp +var transitCell = transition.SpherePath.CurCell; // line 1174 READ swept cell +... +if (transitCell.Equals(CurCell)) { Position.ObjCellID = curPos.ObjCellID; ... } // line 1192 +else change_cell(transitCell); // line 1210 CHANGE only on differ +``` + +**This is the entire flicker story.** Retail's membership is a *latch*: it is +advanced through the sweep and committed at the end, never recomputed from the +static endpoint. acdream re-derives it (see D). + +## A2. `find_cell_list`: building the candidate array, picking the containing cell, the prune + +`CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742)` **[VERIFIED]**. This is +the canonical 6-argument overload; the 3-arg form +(`@ 0x0052b960 (pseudo_c:309085)`) just forwards from a `SPHEREPATH` +(`find_cell_list(&check_pos, num_sphere, global_sphere, cellarray, currcell, path)`). + +Annotated control flow: + +```c +void find_cell_list(Position* pos, uint num_sphere, CSphere* sphere, + CELLARRAY* arr, CObjCell** out_currCell, SPHEREPATH* path) +{ + arr->num_cells = 0; + arr->added_outside = 0; + objcell_id = pos->objcell_id; + visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // :308754 + : CLandCell::GetVisible(objcell_id); // :308756 + + // (1) SEED the array + if (objcell_id >= 0x100) { // interior + if (path) path->hits_interior_cell = 1; + CELLARRAY::add_cell(arr, objcell_id, visibleCell); // :308766 + } else { // outdoor + CLandCell::add_all_outside_cells(pos, num_sphere, sphere, arr); // :308769 + } + + if (visibleCell && num_sphere != 0) { + // (2) EXPAND through portals: each cell adds its reachable neighbours + for (i : arr->num_cells) + arr->cells[i].cell->vtable[+0x80](pos, num_sphere, sphere, arr, path); // find_transit_cells :308782 + + // (3) PICK the single containing cell + if (out_currCell) { + *out_currCell = nullptr; + for (i : arr->num_cells) { + cell = arr->cells[i].cell; + blockOffset = LandDefs::get_block_offset(pos->objcell_id, cell->id); // :308802 + localPoint = sphere->center - blockOffset; + if (cell->vtable[+0x84](&localPoint)) { // point_in_cell :308810 + *out_currCell = cell; + if (cell->id >= 0x100) { // INTERIOR cell wins + if (path) path->hits_interior_cell = 1; + break; // :308819 short-circuit + } + } + } + } + } + + // (4) do_not_load_cells PRUNE + if (arr->do_not_load_cells && (pos->objcell_id & 0xFFFF) >= 0x100) { // :308829 + for (i : arr->num_cells) { + cell_id = arr->cells[i].cell_id; + if (cell_id == visibleCell->id) continue; // keep self (+0x28) + found = false; + for (s : visibleCell->stab_list) // (+0xe0 num, +0xe4 ptr) + if (cell_id == s) { found = true; break; } + if (!found) CELLARRAY::remove_cell(arr, i); // :308863 DROP non-visible cell + } + } +} +``` + +**The four mechanisms, explained:** + +1. **Seed.** Interior position seeds with its own cell; outdoor seeds with the + landcell neighbourhood (`add_all_outside_cells`). +2. **Expand.** `find_transit_cells` (vtable +0x80) adds cells reachable across + the foot-sphere from this cell's portals — this is how a sweep that + straddles a portal sees both cells. +3. **Pick.** The containing cell is the first whose `point_in_cell` returns + true. **An interior cell that contains the point wins and `break`s the + loop** (`:308814-308819`) — interior cells take priority over outdoor + cells. (Without this, a foot sphere overlapping both the doorway landcell + and the vestibule could pick either.) +4. **Prune (`do_not_load_cells`).** When set *and* the position is interior, + remove every candidate that is neither the current cell nor in the current + cell's `stab_list`. The `stab_list` is the cell's *static* set of "cells I + can see." Outdoor landcells are essentially never in an interior cell's + stab list, so when the prune is active, **an interior position cannot have + the array poisoned by outdoor cells** — which is the membership-stability + guarantee. The prune is the "do not stream/consider cells my interior + can't see" rule. + +**`do_not_load_cells` — when is it set?** It is a per-`CELLARRAY` flag +(`acclient.h:31577`, `int do_not_load_cells;` in `struct CELLARRAY`). **[VERIFIED]** +It is set on the *detection / collision* cell arrays used for object-vs-object +queries and for the per-tick transition cell array where you want a stable +neighbourhood that doesn't pull in unloaded/invisible cells. ACE exposes it as +`CellArray.LoadCells` (inverted sense): when `LoadCells == false` the prune +runs. The prune's effect is the stability it buys (item 4 above). **[INFERRED +from struct + ACE]** that the per-tick player transition array is built with +the prune active for interior movers; the decomp's `find_cell_list` only runs +the prune branch when `do_not_load_cells != 0`, and it is unconditionally +correct to enable it for an interior position. + +**ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335` +`find_cell_list` is a near-line-for-line match: seed (`:342-350`), expand +(`:354-359`), pick with interior short-circuit (`:365-385`, the +`return; // break?` at `:381`), prune (`:387-412`, gated on `!cellArray.LoadCells` +and `(position.ObjCellID & 0xFFFF) >= 0x100`, removing cells not in +`((EnvCell)visibleCell).VisibleCells`). + +## A3. Precisely how retail avoids cell flicker + +It is the **combination** of A1 + A2, in this order of importance: + +1. **Swept-path containment with accept-on-move (A1, dominant).** Membership is + advanced only when the sweep crosses into a new cell *and* the crossing + sub-step is accepted; a blocked/standing-still sub-step resets the candidate + back to the held cell (`validate_transition`, `:272593` reset vs `:272612` + advance). The end-of-tick commit reads `curr_cell` and only `change_cell`s + on a real difference (`SetPositionInternal`, `:283414` vs `:283456`). A + ±8 cm push-back is a *block*, which by construction does not move membership. + +2. **Interior-wins short-circuit in `find_cell_list` (A2 item 3).** When a + sphere straddles a doorway and both an interior and an outdoor cell contain + the point, the interior cell is picked and the loop breaks. This prevents + the "should I be in the vestibule or the landcell?" oscillation at the exact + threshold. + +3. **`do_not_load_cells` prune (A2 item 4).** For an interior mover, the + candidate array can never contain an outdoor cell (it's not in the + `stab_list`), so the cross/containment machinery operates on a stable + interior-only neighbourhood. + +4. **`point_in_cell` semantics (BSP, A4).** Containment is a true BSP + point-in-solid test in cell-local space, not an AABB — so the boundaries are + the actual cell geometry, giving crisp, non-overlapping membership at walls. + +**What guarantees a blocked/standing-still step does NOT change the cell:** the +`set_check_pos(curr_pos, curr_cell)` reset at `validate_transition:272593` (block +path) and the `check_pos == curr_pos` no-op path at `:272600-272606` (no move), +combined with `SetPositionInternal`'s `if (this->cell == curr_cell)` guard at +`:283414`. **[VERIFIED]** + +## A4. Transitions: indoor→outdoor, outdoor→indoor, interior→interior; `CCellPortal` vs `CBldPortal` + +Two distinct portal types exist: + +- **`CCellPortal`** (`acclient.h:32300`) **[VERIFIED]** — connects two **interior + EnvCells** (room↔room). Fields: `other_cell_id`, `other_cell_ptr` (cached + neighbour), `portal` (the polygon), `portal_side`, `other_portal_id`, + `exact_match`. An `other_cell_id == 0xffffffff` means the portal opens to the + **outside** (an exit portal). +- **`CBldPortal`** (`acclient.h:32094`) **[VERIFIED]** — connects the **outdoor + landblock to a building's interior** (the door from the street). Fields: + `portal_side`, `other_cell_id`, `other_portal_id`, `exact_match`, + `num_stabs`, `stab_list`, `sidedness`. Held by `CBuildingObj.portals` + (`acclient.h:31908`). + +### Interior→interior (room↔room) + +`CEnvCell::find_transit_cells (sphere variant) @ 0x0052c820 (pseudo_c:309968)` +**[VERIFIED]**: for each `CCellPortal`, get the neighbour via +`CCellPortal::GetOtherCell`, transform the sphere to that cell's local space, +and if `CCellStruct::sphere_intersects_cell != OUTSIDE`, `add_cell` the +neighbour (`:310054`). The `portal_side` field gates direction. When a portal's +`other_cell_id == 0xffffffff` (exterior portal) the function sets `var_44`, +and after the loop: +```c +if (var_44 != 0) CLandCell::add_all_outside_cells(arg2, arg3, arg4, arg5); // :310119-310120 +``` +So an interior cell with an exit portal also pulls in the outdoor landcell +neighbourhood — making the *outdoor* landcell a candidate when you're near the +door from inside. + +### Outdoor→indoor (street→building) + +The landcell carries a building via `CSortCell` (the `CLandCell : CSortCell` +inheritance). `CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603)` +**[VERIFIED]**: +```c +CLandCell::add_all_outside_cells(...); // neighbour landcells +CSortCell::find_transit_cells(this, ...); // the building on this landcell +``` +`CSortCell::find_transit_cells @ 0x00534060 (pseudo_c:318309)` **[VERIFIED]** +→ `CBuildingObj::find_building_transit_cells @ 0x006b5230 (pseudo_c:701214)` +**[VERIFIED]**: +```c +for (i : building->num_portals) { + CBldPortal* p = building->portals[i]; + CEnvCell* interior = CBldPortal::GetOtherCell(p); + if (interior) CEnvCell::check_building_transit(interior, p->other_portal_id, ...); // :701227 +} +``` +`check_building_transit` adds the interior EnvCell to the array if the sphere +has crossed the building portal plane. So when you walk a foot-sphere up to a +door from the street, the interior vestibule becomes a candidate, `point_in_cell` +picks it (interior wins), and `validate_transition` advances `curr_cell` into +the building. + +### Indoor→outdoor (exit) + +As shown in A1 Step 4: when no interior cell contains the sphere center, +`check_other_cells` calls `LandDefs::adjust_to_outside` to convert the interior +cell id to the surrounding landcell id (`pseudo_c:272783`), then re-resolves. +`Position::get_outside_cell_id @ 0x004527b0 (pseudo_c:91552)` **[VERIFIED]** is the +helper that computes the outdoor landcell id from an interior position's frame +(used by render too). + +### How the exit portal / outdoor landcell gets added and chosen + +- *Added*: via `add_all_outside_cells` (triggered by `var_44` in + `find_transit_cells` when an exit portal is present, or directly in the + outdoor seed branch). +- *Chosen*: by `point_in_cell` in the `find_cell_list` pick loop — but the + outdoor cell is only chosen once the sphere center is no longer inside any + interior cell (interior wins, A2 item 3). So there is a clean handoff: you + remain in the interior until the center leaves the interior solid, then the + landcell wins. + +## A5. Two mechanisms or one? — the cell ARRAY vs `curr_cell` + +**They are two distinct things that interact within one transition.** **[VERIFIED]** + +- **`CELLARRAY cell_array`** (`acclient.h:31574`) — a *set* of cells the + collision sweep must test against this tick. Built by `find_cell_list` / + `find_transit_cells`. Used by `insert_into_cell` (the primary cell) and + `check_other_cells` (every other cell in the array). Its purpose is + *collision coverage*: you must collide against walls/floors of every cell + your sphere overlaps, not just one. + +- **`sphere_path.curr_cell` / `check_cell`** — the *single* membership cell. + `curr_cell` = accepted; `check_cell` = candidate this sub-step. + +**How they relate in one transition:** Each sub-step, `check_other_cells` calls +`find_cell_list(&cell_array, &out_containing, &sphere_path)`. This: +(a) rebuilds the `cell_array` (collision set) for the candidate position, and +(b) returns the single containing cell in `out_containing`, which becomes the +new `check_cell` (`check_other_cells:272760-272765`). Then `validate_transition` +promotes `check_cell → curr_cell` on accept. So the **array drives collision**; +the **containing-cell pick drives membership**; both come out of the same +`find_cell_list` call but are different outputs. + +This distinction matters for acdream: acdream already iterates a candidate set +for collision (Phase A4 `CheckOtherCells`, `TransitionTypes.cs:2055-2067`), and +already retargets `CheckCellId` to the containing cell mid-sweep +(`TransitionTypes.cs:2074-2075`). The membership latch is therefore *already +tracked* in `sp.CurCellId`. (See D.) + +--- + +# B. Underground / dungeons + +## B6. How dungeons are represented (dat + runtime), vs building interiors + +**There is no separate dungeon data type.** Both dungeons and building +interiors are made of the same `CEnvCell` graph (`acclient.h:32072`). The +difference is purely in the *landblock* they sit on: + +- A **landblock** (`CLandBlockInfo`, `acclient.h:31893`) **[VERIFIED]** carries + `num_cells` + `cell_ids` + `cells (CEnvCell**)` — the interior cells — *and* + separately `num_buildings` + `buildings (BuildInfo**)`. +- A **dungeon** landblock has terrain heights all zero, ≥1 EnvCell, and **no + buildings**. +- A **building interior** (cottage/inn) sits on a landblock with **real + terrain** and the interior is reached through a `CBldPortal` from the + outdoors; the EnvCells frequently have `seen_outside == 1`. + +**ACE's authoritative definition [VERIFIED]** — +`references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575` `IsDungeon`: +```csharp +// a dungeon landblock is determined by: +// - all heights being 0 +// - having at least 1 EnvCell (0x100+) +// - contains no buildings +foreach (var height in Height) if (height != 0) { isDungeon = false; return; } +isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0; +``` +And `HasDungeon` (`:621`) is the same minus the height check — for landblocks +with both an overworld and a basement (e.g. mansions). + +> **Important caveat:** `IsDungeon`/`HasDungeon` are *ACE server* heuristics for +> spawn/teleport logic. The **retail client** does not compute or need them. The +> client just renders whatever cell the viewer is in (C12). I cite them only to +> characterize the dat layout difference, not as a client code path. **[VERIFIED +> they are ACE-only; INFERRED the client has no equivalent — no such function +> found in the decomp.]** + +**Cell id convention** (the runtime discriminator) **[VERIFIED via decomp use]**: +- Low 16 bits `0x0001..0x0040` (1–64) → an **outdoor landcell** of a landblock + (8×8 grid). `find_cell_list` treats `(id & 0xFFFF) < 0x100` as outdoor + (`pseudo_c:308761`, `308753`). +- Low 16 bits `>= 0x0100` → an **EnvCell** (interior, building or dungeon). +- High 16 bits = the landblock id. So `0xA9B40031` = landblock `0xA9B4`, + outdoor cell `0x31`; `0xA9B40170` = landblock `0xA9B4`, EnvCell `0x170`. + +## B7. Moving through a dungeon: cell tracking, loading, no sky/terrain + +- **Cell tracking** is identical to building interiors — the same + `transition`/`validate_transition`/`change_cell` machinery, just that *every* + cell you cross is an EnvCell (`>= 0x100`) connected by `CCellPortal`s. +- **Loading/streaming.** When the player's cell changes, + `CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311878)` **[VERIFIED]** is + called (via `CellManager::ChangePosition`, see C13). It adds the current cell + + every cell in its `stab_list` to the visible-cell table: + ```c + CEnvCell::add_visible_cell(this->id); + for (i : num_stabs) CEnvCell::add_visible_cell(stab_list[i]); + if (this->seen_outside == 0) return; // :311893 — dungeon: STOP, no landscape + return LScape::grab_visible_cells(landscape); // building: also load the terrain + ``` + This is the precise place the engine decides "load the outdoor world too" or + not: **`seen_outside == 0` → don't touch the landscape** (dungeon). A + building interior with `seen_outside == 1` loads the surrounding terrain. +- **No sky/terrain to draw** is decided by `seen_outside` at render time + (C10/C12): if the viewer cell and all visible cells have `seen_outside == 0`, + no exit portal is ever encountered, so `PView::DrawCells`' + `outside_view.view_count` stays 0 and `LScape::draw` is never called + (`pseudo_c:432715`). **[VERIFIED]** + +## B8. Is there an explicit "underground" flag? + +**No explicit "underground" flag exists in the retail client.** **[VERIFIED — by +absence]**. "Underground" is *emergent*: + +> You are underground iff your current cell is an `EnvCell` (`id & 0xFFFF >= +> 0x100`) whose `seen_outside == 0` and which has no exit-portal path to the +> outdoors. + +The closest thing to a flag is the per-cell **`seen_outside`** boolean +(`CObjCell.seen_outside`, `acclient.h:30929`; dat flag +`EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/.../EnvCellFlags.cs:7`). +**[VERIFIED]**. It is consumed at: +- `CEnvCell::grab_visible_cells:311893` (load landscape or not), +- `SmartBox::RenderNormalMode:92649` and `CellManager::ChangePosition:94575,94649,94682` + (draw landscape / keep landscape loaded). + +There is **no** `is_underground` field on `Position`, on the landblock, or on +the cell. The distinction "dungeon vs building interior vs outdoor" is fully +captured by (cell id range) × (`seen_outside`) × (presence of exit portals). + +--- + +# C. Rendering inside and outside — the seamless seal + +## C9. The single-pass visible-set build (`PView`) + +The render visibility is a **breadth-first portal traversal** rooted at the +viewer's cell, producing an ordered cell-draw list plus per-portal screen +clip regions. The data structures (`acclient.h:32346`) **[VERIFIED]**: +```c +struct portal_view_type { // one per EnvCell (CEnvCell.portal_view) + DArray portal; // per-portal seen/inflag state + view_type view; // poly + vertex screen-clip geometry + float max_indist; + unsigned int view_count; + int cell_view_done; + int view_timestamp; + int update_count; +}; +struct view_type { unsigned vertex_count_total; DArray poly; DArray vertex; }; +``` + +### `PView::ConstructView(CEnvCell* root, ushort) @ 0x005a57b0 (pseudo_c:433750)` **[VERIFIED]** + +```c +this->outside_view.view_count = 0; +PView::master_timestamp += 1; +this->cell_todo_num = 0; this->cell_draw_num = 0; +PView::InitCell(this, root, arg3); // compute root's active portals + clip +PView::InsCellTodoList(this, root, 0); // push root onto BFS todo +while (true) { + if (cell_todo_num <= 0) return; + cell = cell_todo_list[--cell_todo_num].cell; + if (cell == 0) return; + cell_draw_list[cell_draw_num++] = cell; // ADD to visible draw list + cell->portal_view[...]->cell_view_done = 1; + if (PView::ClipPortals(this, cell, 0)) // clip this cell's portals to current view + PView::AddViewToPortals(this, cell); // enqueue neighbours through visible portals +} +``` + +### `PView::InitCell @ 0x005a4b70 (pseudo_c:432896)` **[VERIFIED]** + +For the cell, walks `num_portals`, computes each portal polygon's plane vs the +viewer (`Render::FrameCurrent->viewer.viewpoint`), and sets per-portal +`inflag`/`seen` based on `portal_side` (the side the viewer is on). Computes +`max_indist` (`esi[0xd]`). This decides which of the cell's portals can be seen +through from the current viewpoint. + +### `PView::AddViewToPortals @ 0x005a52d0 (pseudo_c:433446)` **[VERIFIED]** + +For each *active* portal of the cell: +```c +esi_2 = portal.other_cell_ptr; // neighbour EnvCell +if (esi_2 && portal_active) { + if (neighbour not yet inited) { + InitCell(this, esi_2, portal.other_portal_id); + InsCellTodoList(this, esi_2, max_indist); // enqueue neighbour + if (portal.flag >= 0) SetOtherSeen(this, cell, portal_idx); // record exit-portal seen + } else AddToCell(...); // merge additional view +} +``` +This is the recursive portal expansion: each visible portal pulls its +neighbour cell into the todo list, clipped to the portal's screen region. + +### Output + +- **`cell_draw_list[0..cell_draw_num]`** — the ordered set of visible EnvCells. +- Per-cell **`portal_view[...]->view`** — the accumulated screen-space clip + region(s) (poly+vertex) through which each cell is visible (so a far cell + seen through two doorways is clipped to the intersection). +- **`outside_view`** — accumulated clip region for the outdoors seen through + exit portals (drives `LScape::draw`, C10). + +**Cross-check:** WorldBuilder and ACViewer do **NOT** implement this. ACViewer +brute-force draws all loaded EnvCells with a `DungeonMode` cull-mode toggle +(`references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564`) **[VERIFIED]** — +no portal-recursive clip. WorldBuilder uses a flat stencil "inside-out" pass +(per project memory `project_indoor_portal_visibility.md`). So **`PView` is the +unique authority** for the seamless seal; the references diverge here and the +decomp wins. + +## C10. Drawing the OUTSIDE through a doorway (no blue hole) + +Two cooperating mechanisms. + +### (a) Exit portals carry the landscape into the indoor traversal + +In `PView::ClipPortals @ 0x005a5520 (pseudo_c:433572)` **[VERIFIED]**, when a +portal's `other_cell_id == 0xffffffff` (an exterior portal): +```c +if (*(uint32_t*)esi_3 == 0xffffffff) { // :433662 exit portal + if (this->draw_landscape) { // :433664 + if (cliplandscape) Render::copy_view(this, &clip_view, ecx_8); // :433674 — landscape clipped to portal + else if (draw_landscape) Render::copy_view(this, nullptr, 0); + } +} +``` +So the doorway's screen region (`clip_view`) is registered as a region through +which the **landscape** is drawn. The `this->draw_landscape` flag is set when +the indoor PView is allowed to show outdoors. + +### (b) `DrawCells` draws the landscape first when any portal opened outside + +`PView::DrawCells @ 0x005a4840 (pseudo_c:432709)` **[VERIFIED]**: +```c +if (this->outside_view.view_count > 0) { // :432715 some portal saw outside + Render::useSunlightSet(1); + Render::PortalList = this; + LScape::draw(this->lscape); // :432719 DRAW THE OUTDOOR WORLD (terrain+sky+buildings) + D3DPolyRender::FlushAlphaList(0); + ... + // CONDITIONAL clear — only clears Z where portals were drawn: + if (forceClear || D3DPolyRender::portalsDrawnCount != 0) + RenderDevice->Clear(4, 0x820fc0, 1.0); // :432731-432732 (4 == Z-only) + // then draw each visible EnvCell's drawing_bsp + portal polys: + for (cell : cell_draw_list) { + if (cell->structure->drawing_bsp) { + ... setup_view per portal_view ... + for (portal : cell->portals) + if (portal.other_cell_id == 0xffffffff) + D3DPolyRender::DrawPortalPolyInternal(portal.portal, 0); // :432785-432786 stencil the opening + } + } + for (cell : cell_draw_list) RenderDevice->DrawEnvCell(cell); // :432853 draw interior geometry +} +``` + +**Why there is no blue clear-color hole:** +- The landscape (terrain + sky + exterior buildings) is drawn *first*, **inside + the doorway clip region**, when `outside_view.view_count > 0`. +- The `Clear` is `Clear(4, …)` — flag `4` is the **Z-buffer**, not the full + color buffer; and it is *conditional* on portals having been drawn. There is + no unconditional `Clear(color)` painting the frame blue. +- The exit-portal polygons are drawn as stencil masks (`DrawPortalPolyInternal`) + so the outdoors only shows through the actual door opening. + +So from inside a cottage, looking at the door, you see the real terrain/sky/ +rain through the opening — clipped to the door — and the walls around it. The +"blue hole" in acdream is precisely the *absence* of step (b): acdream's indoor +pass clears to clear-color and never injects the landscape into the doorway +clip. + +`PView::DrawInside @ 0x005a5860 (pseudo_c:433793)` **[VERIFIED]** is the top-level +indoor entry that ties it together: +```c +CEnvCell::curr_view_push(viewer_cell); +PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // seed PVS from stab list +Render::copy_view(viewer_cell->portal_view[...], nullptr, 4); +edx = PView::ConstructView(this, viewer_cell, 0xffff); // build visible set +PView::DrawCells(this, edx); // draw (incl. landscape thru doors) +PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); +``` + +`PView::ConstructView(CBldPortal*, CPolygon*, …) @ 0x005a59a0 (pseudo_c:433827)` +**[VERIFIED]** is the *building-portal* sibling (used by `DrawPortal`/outdoor +pview): it tests viewer-vs-portal-plane sidedness, gets the interior via +`CEnvCell::GetVisible(other_cell_id)`, clips, and recurses +`ConstructView(interior, other_portal_id)` (`:433879`). This is how the +**outdoor** render draws *into* a building when the camera can see the door +from the street — the mirror image of (a)/(b). + +## C11. Sealing interiors: capped ceilings, no bleed-through, entity/particle clipping + +- **Ceilings/walls are sealed by construction.** An EnvCell's geometry + (`CCellStruct.polygons` + `drawing_bsp`, `acclient.h:32285`) is a *closed* + cell — the dat authoring includes the ceiling/floor/walls as polygons. There + is no "cap the ceiling" step; the cell mesh is already a sealed box with + portal holes only where `CCellPortal`s exist. `DrawCells` draws + `cell->structure->drawing_bsp` for each visible cell (`pseudo_c:432745`, + `432853`). **[VERIFIED]** +- **No outdoor bleed-in.** Because the visible set is *only* the cells reached + by the portal BFS (`cell_draw_list`), and the landscape is drawn *only* + through exit-portal clip regions, the outdoor world cannot paint over + interior walls. If `seen_outside == 0` everywhere reachable, the landscape is + never drawn at all. +- **Entity/particle clipping to visible cells.** Objects live in a cell's + `shadow_object_list` / `object_list` (`CObjCell.object_list`, + `acclient.h:30920`). `DrawCells` draws per-cell objects via + `DrawObjCellForDummies(cell)` (`pseudo_c:432878`) walking only the + `cell_draw_list`. An object in a non-visible cell is simply not iterated. + `Render::PortalList` is set per cell so object draws inherit the same clip + region (`:432877`). **[VERIFIED]** Particles attached to objects follow the + object's cell membership. + +## C12. Terrain + sky: drawn or not, as a function of the current cell + +The decision is made once per frame in +`SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635)` **[VERIFIED]**: +```c +// edi_2 ≈ "viewer is in an outdoor landcell" (decompiler-garbled, but the branch is binary) +if (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ebx_1 = 1; // :92649 outside-relevant +else ebx_1 = 0; + +if (edi_2 == 0) { // VIEWER IS INSIDE AN ENVCELL + if (ebx_1 != 0) { // cell seen_outside: update landscape viewpoint too + eax_1 = Position::get_outside_cell_id(&this_1->viewer); + LScape::update_viewpoint(this_1->lscape, eax_1); + } + Render::update_viewpoint(&this_1->viewer); + RenderDevice->DrawInside(viewer_cell); // :92675 indoor PView path (draws landscape thru doors if seen_outside) +} else { // VIEWER IS OUTSIDE (landcell) + LScape::update_viewpoint(this_1->lscape, this_1->viewer.objcell_id); + Render::update_viewpoint(&this_1->viewer); + Render::set_default_view(); + Render::useSunlightSet(1); + LScape::draw(this_1->lscape); // :92683 normal outdoor render +} +``` + +So: +- **Viewer in an outdoor landcell** (`id & 0xFFFF < 0x100`) → `LScape::draw` + (full terrain + sky + buildings; buildings draw *their* interiors via + building-portal `ConstructView` when the camera sees the door). +- **Viewer in an EnvCell** → `DrawInside` (portal PVS). Terrain/sky appear + **only** through exit portals, and only if some reachable cell has + `seen_outside`. A dungeon (`seen_outside == 0` everywhere) shows no + terrain/sky. + +This is a single binary on the current cell's id range + `seen_outside`. No +separate "is it raining indoors?" logic — rain/sky come through the doorway +because the *landscape* is drawn through the exit-portal clip. + +## C13. Is render's cell the SAME `curr_cell`/graph as physics? + +**Yes — render reads the same `objcell_id` and traverses the same cell graph.** +**[VERIFIED]** + +`CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601)` **[VERIFIED]** is the +render-side cell tracker, driven by the *position's* `objcell_id` (the same id +physics maintains): +```c +uint objcell_id = arg2->objcell_id; // the Position's cell id +if (objcell_id == 0) { CellManager::Reset(this); return; } +... +if (load_pos.objcell_id != objcell_id || curr_cell == 0) { + CellManager::PreFetchCells(this, objcell_id, edi); + ... + CObjCell* eax_2 = CObjCell::Get(arg2->objcell_id); // :94640 RESOLVE from the same id + if (eax_2) { + if (ebp_2) { eax_2->grab_visible_cells(); curr_cell = eax_2; } + else if (eax_2->seen_outside || keep_lscape_loaded) { // :94649 keep landscape if seen_outside + LScape::update_loadpoint(lscape, Position::get_outside_cell_id(arg2)); + eax_2->grab_visible_cells(); curr_cell = eax_2; + } else { LScape::release_all(lscape); eax_2->grab_visible_cells(); curr_cell = eax_2; } + } + ... + Render::player_pos.objcell_id = curr_cell->pos.objcell_id; // :94671 +} +``` + +The render `CellManager.curr_cell` is `CObjCell::Get(position.objcell_id)` — the +same cell graph (`CObjCell::GetVisible`/`CEnvCell::visible_cell_table`) the +physics uses (`find_cell_list` resolves `GetVisible(objcell_id)` too, +`pseudo_c:308754/308756`). The viewer's cell (`SmartBox.viewer_cell`) is the +camera's cell; in 1st person it is the player's cell; in 3rd person the camera +may be in a neighbour cell, but it is resolved through the *same* graph. + +`CObjCell::GetVisible @ 0x0052ad40 (pseudo_c:308209)` **[VERIFIED]** is the shared +resolver: +```c +return (arg1 >= 0x100) ? CEnvCell::GetVisible(arg1) : CLandCell::GetVisible(arg1); +``` +`CEnvCell::GetVisible @ 0x0052dc10 (pseudo_c:311378)` is a hashtable lookup in +`CEnvCell::visible_cell_table`; `CLandCell::GetVisible @ 0x00532db0 +(pseudo_c:316986)` → `LScape::get_landcell`. **[VERIFIED]** + +**Conclusion (central to acdream's decision):** retail has **one** cell graph +and **one** `objcell_id`. Physics maintains `curr_cell`; render reads +`position.objcell_id` and resolves through the same `GetVisible`. There is no +separate render-side cell discovery from the camera XYZ — the render cell *is* +the physics cell (for the player/viewer). The PVS traversal (`PView`) then +expands from that single root through the shared `CEnvCell::portals`. + +--- + +# D. Synthesis for acdream + +## D-pre. What acdream does today (the gap, precisely) + +Read in source **[VERIFIED]**: + +1. **The sweep already tracks the cell correctly.** acdream's + `SpherePath` has `CurCellId` + `CheckCellId` + (`src/AcDream.Core/Physics/TransitionTypes.cs:335-336`), and its + `ValidateTransition` advances/resets them exactly like retail: + ```csharp + // TransitionTypes.cs:3404-3434 + if (transitionState == OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); } + else if (transitionState == OK) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); } + else if (transitionState != Invalid) { ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); transitionState = OK; } + ``` + It also retargets `CheckCellId` to the containing cell mid-sweep + (`TransitionTypes.cs:2061-2075`, the Phase A4 `FindCellSet` + + `SetCheckPos(sp.CheckPos, containingCellId)`). + +2. **The engine THROWS AWAY the tracked cell and re-derives statically.** This + is the bug. `PhysicsEngine.ResolveWithTransition` builds the result: + ```csharp + // PhysicsEngine.cs:907-912 (OK path) + resolveResult = new ResolveResult( + sp.CheckPos, + ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), // :909 RE-DERIVE, sp.CheckCellId only a fallback + onGround, collisionNormalValid, collisionNormal); + // PhysicsEngine.cs:926-931 (partial/blocked path) + resolveResult = new ResolveResult( + sp.CheckPos, + ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), // :928 RE-DERIVE + ...); + ``` + `ResolveCellId @ PhysicsEngine.cs:294` then re-runs `CellTransit.FindCellList` + (indoor) or a terrain-grid lookup (outdoor) **from the static endpoint**, + and even re-verifies with a sphere-overlap test + a "doorway hold margin" + band-aid (`PhysicsEngine.cs:347-363, 402-421`). This is the static + re-derive the prompt identified: jitter at the boundary flips the answer. + +3. **No `do_not_load_cells` prune.** `CellTransit.BuildCellSetAndPickContaining` + (`CellTransit.cs:426`) builds the candidate set and picks the *first* + candidate whose `PointInsideCellBsp` is true (`:524-534`) — with **no + interior-wins short-circuit** and **no stab-list prune**. HashSet iteration + order makes the pick non-deterministic when multiple cells contain the + center. + +4. **Render maintains a SEPARATE cell system.** `CellVisibility.FindCameraCell` + (`src/AcDream.App/Rendering/CellVisibility.cs:389`) independently re-derives + the camera cell from the eye position, *with a 3-frame grace-frame hack* + (`CellSwitchGraceFrameCount = 3`, `:214`), ported from ACME's flat-stencil + `EnvCellManager` — not retail's PView. `GetVisibleCells` (`:521`) is a + separate BFS. This is the root of the "render strobes / blue hole / bleed" + family. + +5. **The unified seam already exists.** W1 shipped `CellGraph` + (`src/AcDream.Core/World/Cells/CellGraph.cs`) with `GetVisible(id)` (the + retail resolver) and `CurrCell` (the single membership answer), plus faithful + `EnvCell` (`StabList`, `Portals`, `SeenOutside` from `EnvCellFlags.SeenOutside`, + `ContainmentBsp`, `PointInCell`). `CurrCell` is currently written by + `SetCurrAndReturn` (`PhysicsEngine.cs:287`) but from the *re-derived* id, and + it has **no render reader yet**. + +## D14. The retail-faithful target architecture + +**Adopt retail's "track membership through the sweep; one cell graph for +physics + render; one portal-visibility traversal" model.** Concretely: + +1. **Membership is a latch tracked through the sweep, committed at the end.** + `ResolveWithTransition` must return `sp.CurCellId` (the value advanced by + `ValidateTransition` / retargeted by the mid-sweep containing-cell pick), + **not** a re-derive from the final XYZ. The static `ResolveCellId` is + *deleted* from the per-tick output path. This mirrors + `CPhysicsObj::SetPositionInternal(CTransition*)` reading + `sphere_path.curr_cell` (`pseudo_c:283403`) and `change_cell`-on-differ + (`:283414/283456`). + +2. **`PlayerMovementController.CellId` becomes "set from `sp.CurCellId`, + change only on differ."** Add an explicit "did the cell change?" event so the + render and streaming can react (the analog of `change_cell` / + `CellManager::ChangePosition`). Write `CellGraph.CurrCell = GetVisible(newId)` + here — once, authoritatively. + +3. **`CellTransit` gets the interior-wins short-circuit + the + `do_not_load_cells` prune.** In `BuildCellSetAndPickContaining`: when a + candidate is interior and contains the center, pick it and break + (`find_cell_list:308814-308819`); and when the seed is interior, prune every + candidate not equal to the seed cell and not in the seed cell's `StabList` + (`find_cell_list:308829-308867`). This restores membership stability and + makes the pick deterministic — and lets us delete the `DoorwayHoldMargin` + band-aid (`PhysicsEngine.cs:416-421`) and the sphere-overlap re-verify + (`:347-363`), since the latch + prune make them unnecessary. + +4. **Render obeys the physics `CurrCell` and runs ONE portal-visibility + traversal.** Replace `CellVisibility.FindCameraCell`'s independent + re-derive + grace-frames with: *root the PVS at the player's `CellGraph.CurrCell`* + (camera/eye still drives projection, but the visibility ROOT is the physics + cell — this is exactly the flap fix already discovered, CLAUDE.md U.4c). For + the camera in 3rd person, resolve the camera's cell through the *same* + `CellGraph.GetVisible` if needed, but prefer the player cell as root (retail's + `viewer_cell` is the camera cell, but acdream's chase camera is the source of + the stale-cell flap — keep root = player cell, per the shipped U.4c + decision). Then port the `PView::ConstructView` BFS to produce one visible + cell list + exit-portal clip regions, and one decision (`SmartBox::RenderNormalMode`): + outdoor landcell → terrain pipeline; EnvCell → indoor pipeline that draws the + landscape through exit portals when `seen_outside`. + +5. **Terrain/sky gating keys off the current cell's id range + `seen_outside`, + exactly once.** `(CurrCell.Id & 0xFFFF) < 0x100` → draw terrain/sky. + `EnvCell && seen_outside` → draw terrain/sky **only through exit-portal clip + regions**. `EnvCell && !seen_outside` (dungeon) → never draw terrain/sky. + This replaces the abandoned two-pipe (`cameraInsideBuilding`) split. + +## D15. Should membership be advanced in the sweep, and should render obey it? + +**Yes to both. Justified directly from the decomp:** + +- **Advance membership inside the sweep (port `validate_transition`'s + `curr_cell` advance + the prune + drop the static re-derive):** + `validate_transition` is the *only* place retail mutates `curr_cell`, and it + does so as a function of whether the swept sub-step was accepted + (`pseudo_c:272612` advance vs `:272593` reset). `SetPositionInternal` reads + exactly that (`pseudo_c:283403`) and commits via `change_cell`-on-differ. + There is **no static endpoint re-derivation anywhere** in retail's per-tick + path — `find_cell_list` is called *during* the sweep (in `check_other_cells`) + and *at the end with `arg5 == nullptr`* (so it does not pick membership). The + `do_not_load_cells` prune is part of that same `find_cell_list` + (`pseudo_c:308829`). acdream already has the latch infrastructure + (`sp.CurCellId` + `ValidateTransition`); the fix is to *use it* at the engine + output and add the prune. + +- **Render obeys the physics `curr_cell` + a single portal traversal:** + `CellManager::ChangePosition` resolves render's `curr_cell` from + `position.objcell_id` via `CObjCell::Get` — the *same* graph and id physics + maintains (`pseudo_c:94640`). `SmartBox::RenderNormalMode` keys the single + inside/outside decision off `viewer_cell` (`pseudo_c:92649,92665`). `PView` + expands from that one root. acdream's separate `CellVisibility.FindCameraCell` + with grace-frames is non-retail and is the documented source of the + flap/strobe; rooting the PVS at the shared `CurrCell` (W2/W3) is the + retail-faithful replacement. **[VERIFIED across decomp + ACE + acdream code]** + +The risk the prompt flags ("touching the collision sweep where acdream has a +history of bugs") is **largely avoided** by this plan: the sweep itself is +*not* changed (its `CurCellId` tracking is already correct and tested). We +change (a) the engine *output* (return `sp.CurCellId`), (b) add a prune in +`CellTransit` *pick*, and (c) the *render* root. None of these alter +`insert_into_cell` / `FindEnvCollisions` / step-up / AdjustOffset — the parts +that have historically broken (issue #98 etc.). This is a key safety property. + +## D16. Must-port functions, integration order, risks, conformance tests + +### Must-port / must-align functions (with decomp addresses) + +Physics membership (mostly *already present* in acdream — align, don't rewrite): + +| Retail function | Address (pseudo_c) | acdream status / action | +|---|---|---| +| `CTransition::validate_transition` | `0x0050aa70` (272547) | Present as `ValidateTransition` (`TransitionTypes.cs:3398`). **Keep.** | +| `CObjCell::find_cell_list` | `0x0052b4e0` (308742) | Present as `CellTransit.BuildCellSetAndPickContaining`. **Add interior-wins break + `do_not_load_cells` prune (`:308814-308867`).** | +| `CTransition::check_other_cells` | `0x0050ae50` (272717) | Present as `CheckOtherCells` + mid-sweep retarget (`TransitionTypes.cs:2061-2075`). **Keep; ensure exit→`adjust_to_outside` path exists.** | +| `CPhysicsObj::SetPositionInternal(CTransition*)` | `0x00515330` (283399) | **Port the read-`CurCell`/commit-on-differ contract into `ResolveWithTransition`** — return `sp.CurCellId`, drop static `ResolveCellId` at `PhysicsEngine.cs:909/928`. | +| `CPhysicsObj::change_cell` | `0x00513390` (281192) | acdream uses a spatial registry, not per-cell shadow lists. **Emit a "cell changed" event when `sp.CurCellId` differs**; align registration with retail's per-cell `shadow_object_list` over time (see memory `feedback_retail_per_cell_shadow_list.md`). | +| `LandDefs::adjust_to_outside` | `0x005a9bc0` (438719) | Needed for the interior→outdoor exit id (`check_other_cells:272783`). **Verify acdream's `AddAllOutsideCells` / `Position.get_outside_cell_id` equivalent covers this.** | +| `CBuildingObj::find_building_transit_cells` | `0x006b5230` (701214) | Present as `CheckBuildingTransit`. **Keep.** | +| `CEnvCell::find_transit_cells` (sphere) | `0x0052c820` (309968) | Present as `FindTransitCellsSphere`. **Keep.** | +| `CLandCell::add_all_outside_cells` | `0x00533630` (317499) | Present as `AddAllOutsideCells`. **Keep** (see memory `feedback_latent_bug_masked_by_fallback.md` re: coord convention). | + +Render visibility (port fresh — this is the unified-pipeline work, Phase U/W3): + +| Retail function | Address (pseudo_c) | acdream action | +|---|---|---| +| `SmartBox::RenderNormalMode` | `0x00453aa0` (92635) | Port the single inside/outside decision keyed off `CurrCell` id-range + `seen_outside`. | +| `CellManager::ChangePosition` | `0x004559b0` (94601) | Port: render resolves cell from `objcell_id` via `CellGraph.GetVisible`; `grab_visible_cells` on change; keep-landscape when `seen_outside`. | +| `CEnvCell::grab_visible_cells` | `0x0052e220` (311878) | Port: add self+`stab_list` to visible set; load landscape iff `seen_outside`. | +| `PView::ConstructView` (cell) | `0x005a57b0` (433750) | Port the BFS visible-cell-list builder. | +| `PView::InitCell` | `0x005a4b70` (432896) | Port per-portal sidedness/clip init. | +| `PView::ClipPortals` | `0x005a5520` (433572) | Port; **the `other_cell_id == 0xffffffff` branch (`:433662`) is the landscape-through-doorway hook.** | +| `PView::AddViewToPortals` | `0x005a52d0` (433446) | Port neighbour enqueue + `SetOtherSeen`. | +| `PView::DrawCells` | `0x005a4840` (432709) | Port: `if outside_view.view_count>0 → LScape::draw` first; conditional Z-clear (NOT color); per-cell `drawing_bsp`; exit-portal stencil. | +| `PView::DrawInside` | `0x005a5860` (433793) | Port the top-level indoor entry. | +| `PView::ConstructView` (bld portal) | `0x005a59a0` (433827) | Port the outdoor→building recursion (camera sees door from street). | + +### Integration order (lowest-risk first) + +1. **W2/W3-physics: stop discarding the swept cell.** Make + `ResolveWithTransition` return `sp.CurCellId`; route it to + `PlayerMovementController.CellId` and `CellGraph.CurrCell`. Delete the static + `ResolveCellId` call at `:909/928` (keep `ResolveCellId` only for non-sweep + teleport/spawn seeding). *Smallest, highest-leverage change; does not touch + collision math.* Expect the boundary flicker to vanish immediately. +2. **CellTransit pick fix.** Add interior-wins short-circuit + the + `do_not_load_cells` prune in `BuildCellSetAndPickContaining`. Then delete the + `DoorwayHoldMargin` hold and the sphere-overlap re-verify in `ResolveCellId` + (now dead for the per-tick path). *Removes band-aids; makes pick + deterministic.* +3. **Render reads `CurrCell`.** Re-root `CellVisibility` at + `CellGraph.CurrCell` (player cell), remove `FindCameraCell`'s independent + re-derive + the 3-frame grace hack. *This alone fixes the threshold strobe.* +4. **Single PVS + landscape-through-door.** Port `PView::ConstructView` / + `DrawCells` so the indoor pass draws the landscape through exit-portal clips + (kills the blue hole) and the single `RenderNormalMode` decision replaces the + two-pipe split. *Largest piece; do last, gated on visual verification.* +5. **(Longer term) per-cell shadow lists.** Migrate collision registration from + the landblock-wide spatial registry to retail's per-cell `shadow_object_list` + to close the indoor/outdoor seam issues noted in memory. + +### Risks + +- **Velocity/position consumers of the cell.** Anything that today reads the + re-derived cell (streaming radius, audio cell, picking) must switch to + `CurrCell`. Audit `SetCurrAndReturn` call sites. *Mitigation:* make the change + event explicit; one writer. +- **Spawn/teleport seeding still needs a static resolve.** Keep `ResolveCellId` + for `MoveOrTeleport`-equivalent seeding (retail's `SetPositionInternal(Position*,…)` + path at `pseudo_c:283892` also resolves from scratch). Don't delete it + wholesale — only remove it from the per-tick *transition output*. +- **`do_not_load_cells` prune over-pruning.** If acdream's `StabList` for a cell + is incomplete (dat parse gap), the prune could drop a legitimately-overlapping + neighbour and cause a wall to be missed. *Mitigation:* the prune only runs for + interior seeds and only removes non-stab cells; verify `EnvCell.StabList` + population against `datCell.VisibleCells` (already done in `EnvCell.FromDat`). +- **PVS perf / portal-graph blowup (issue #95).** Port `master_timestamp` + + `cell_view_done` cycle-guards from `PView` to bound the BFS; cap with + `max_indist`. *This is exactly what acdream's separate BFS lacks.* +- **Render root vs camera.** Retail's `viewer_cell` is the *camera* cell; + acdream's chase camera drifts out of the player cell ~79% of frames (CLAUDE.md + U.4c), so **root the PVS at the player cell** (the shipped flap fix), keeping + the eye for projection only. Do not regress to camera-rooted visibility. + +### Conformance tests (prove faithfulness) + +1. **Membership latch (no static re-derive).** Replay the captured cottage/ + cellar trajectory (`CellarUpTrajectoryReplayTests` + `ACDREAM_CAPTURE_RESOLVE` + JSONL) and assert the per-tick output cell equals the *swept* `sp.CurCellId`, + never a re-derive. Specifically: a held-still tick at the doorway must keep + the prior cell (no `0x0170↔0x0031` flip). Golden source: live capture shows + 1 cell-transit (login) vs 20+ pre-fix (CLAUDE.md A6.P3 slice 3). +2. **Block does not change cell.** Unit test: seed `CurCell=A`, run a transition + whose only accepted sub-steps stay in A but whose final push-back nudges the + center ~8 cm across the A/B boundary; assert `sp.CurCellId == A` and the + committed cell == A. (Directly tests `validate_transition` reset path.) +3. **Interior-wins pick.** Unit test: sphere center inside both a doorway + landcell and the vestibule EnvCell; assert `BuildCellSetAndPickContaining` + returns the EnvCell and short-circuits. +4. **`do_not_load_cells` prune.** Unit test: interior seed cell with a known + `StabList`; inject an outdoor candidate not in the stab list; assert it is + removed. Mirror ACE's `find_cell_list:387-412`. +5. **Exit-to-outdoor id.** Unit test: from an interior cell with an exit portal, + walk the foot sphere out the door; assert `check_other_cells` produces the + correct outdoor landcell id via `adjust_to_outside`. +6. **Render PVS == physics cell.** Integration test: assert the PVS root cell id + equals `CellGraph.CurrCell.Id` every frame (no independent camera re-derive). +7. **Seamless seal (visual + headless).** Headless: in a cottage with a known + exit portal, assert `PView.outside_view.view_count > 0` and that `LScape::draw` + equivalent is invoked (terrain drawn) — i.e. no blue clear-color in the + doorway region. Visual: the user confirms sky/rain visible through the door, + ceiling sealed, no terrain bleed. (Visual verification is the acceptance gate + per CLAUDE.md.) +8. **Dungeon: no terrain/sky.** Headless: in a `seen_outside == 0` EnvCell graph + (dungeon), assert the landscape is never grabbed (`grab_visible_cells` returns + before `LScape::grab_visible_cells`) and `outside_view.view_count == 0`. + +--- + +## Appendix — disagreements & uncertainties + +- **`seen_outside` field overload in `CEnvCell::UnPack`.** At + `pseudo_c:311044` the decompiler shows `this->seen_outside = ` + and indexes it with frames — inconsistent with the verbatim header's + `int seen_outside`. This is BN's heuristic field-naming colliding with an + adjacent allocation (likely the `voyeur_table`/light-frame array). The + **authoritative** facts are (a) the verbatim header `int seen_outside` + (`acclient.h:30929`), (b) its boolean use in render (`pseudo_c:92649,94575, + 311893`), and (c) the dat flag `EnvCellFlags.SeenOutside = 0x1`. I treat + `seen_outside` as a per-cell boolean. **[flagged as decomp-naming noise]** +- **`edi_2` in `RenderNormalMode`.** The decompiler garbles the "viewer is in an + outdoor landcell" predicate (`pseudo_c:92644-92646`). The branch is clearly + binary (`edi_2 == 0` → `DrawInside`, else `LScape::draw`), and the + `viewer_cell->seen_outside` term is intact. I infer `edi_2 != 0` ⇔ viewer cell + is a landcell. **[INFERRED from branch structure + the parallel + `CellManager::ChangePosition` `ebx = (objcell_id < 0x100)` test at + `pseudo_c:94568`]** +- **`do_not_load_cells` set-site for the player transition.** I verified the + *flag's effect* (`find_cell_list:308829`) and ACE's `LoadCells` semantics, but + did not pin the exact retail call that sets it on the per-tick player array. + The prune is unconditionally correct for interior seeds, so acdream can enable + it for interior membership resolution regardless. **[INFERRED]** +- **References diverge on the seal.** ACViewer (brute-force draw all EnvCells + + `DungeonMode` cull toggle) and WorldBuilder (flat stencil inside-out) do **not** + implement `PView`'s portal-clipped landscape-through-door. The decomp is the + sole authority for C9–C11, and it wins. **[VERIFIED divergence]** + +--- + +### Citation index (primary decomp anchors) + +``` +CPhysicsObj::transition 0x00512dc0 pseudo_c:280904 +SPHEREPATH::init_path 0x0050ce20 pseudo_c:274359 +CTransition::find_valid_position 0x0050c310 pseudo_c:273890 +CTransition::find_transitional_position 0x0050bdf0 pseudo_c:273613 +CTransition::transitional_insert 0x0050b6f0 pseudo_c:273137 +CTransition::insert_into_cell 0x00509e70 pseudo_c:271991 +CTransition::check_other_cells 0x0050ae50 pseudo_c:272717 +CTransition::validate_transition 0x0050aa70 pseudo_c:272547 +CObjCell::find_cell_list (6-arg) 0x0052b4e0 pseudo_c:308742 +CObjCell::GetVisible 0x0052ad40 pseudo_c:308209 +CEnvCell::GetVisible 0x0052dc10 pseudo_c:311378 +CLandCell::GetVisible 0x00532db0 pseudo_c:316986 +CEnvCell::point_in_cell 0x0052c300 pseudo_c:309677 +CLandCell::point_in_cell 0x00532d40 pseudo_c:316941 +CEnvCell::find_transit_cells (sphere) 0x0052c820 pseudo_c:309968 +CLandCell::find_transit_cells 0x00533800 pseudo_c:317603 +CSortCell::find_transit_cells 0x00534060 pseudo_c:318309 +CBuildingObj::find_building_transit_cells 0x006b5230 pseudo_c:701214 +CLandCell::add_all_outside_cells 0x00533630 pseudo_c:317499 +LandDefs::adjust_to_outside 0x005a9bc0 pseudo_c:438719 +Position::get_outside_cell_id 0x004527b0 pseudo_c:91552 +CPhysicsObj::SetPositionInternal(CTrans)0x00515330 pseudo_c:283399 +CPhysicsObj::change_cell 0x00513390 pseudo_c:281192 +CPhysicsObj::UpdateObjectInternal 0x005156b0 pseudo_c:283611 +CEnvCell::find_visible_child_cell 0x0052dc50 pseudo_c:311397 +CEnvCell::grab_visible_cells 0x0052e220 pseudo_c:311878 +SmartBox::RenderNormalMode 0x00453aa0 pseudo_c:92635 +CellManager::ChangePosition 0x004559b0 pseudo_c:94601 +PView::ConstructView (CEnvCell) 0x005a57b0 pseudo_c:433750 +PView::ConstructView (CBldPortal) 0x005a59a0 pseudo_c:433827 +PView::InitCell 0x005a4b70 pseudo_c:432896 +PView::ClipPortals 0x005a5520 pseudo_c:433572 +PView::AddViewToPortals 0x005a52d0 pseudo_c:433446 +PView::DrawCells 0x005a4840 pseudo_c:432709 +PView::DrawInside 0x005a5860 pseudo_c:433793 + +Structs (acclient.h): +SPHEREPATH 32625 | CObjCell 30915 | CEnvCell 32072 | CLandCell 31886 +CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 | CELLARRAY 31574 +CELLINFO 31925 | CLandBlockInfo 31893 | CBuildingObj 31908 | portal_view_type 32346 + +References cross-checked: +ACE ObjCell.find_cell_list references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335 +ACE Transition.ValidateTransition references/ACE/Source/ACE.Server/Physics/Transition.cs:984 +ACE PhysicsObj.SetPositionInternal references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171 +ACE Landblock.IsDungeon references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575 +ACViewer EnvCellFlags references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7 +ACViewer Buffer (no PVS) references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564 + +acdream current code: +PhysicsEngine.ResolveCellId src/AcDream.Core/Physics/PhysicsEngine.cs:294 +PhysicsEngine static re-derive src/AcDream.Core/Physics/PhysicsEngine.cs:909,928 +SpherePath CurCellId/CheckCellId src/AcDream.Core/Physics/TransitionTypes.cs:335-336 +ValidateTransition (advance/reset) src/AcDream.Core/Physics/TransitionTypes.cs:3398-3434 +CellTransit pick (no prune) src/AcDream.Core/Physics/CellTransit.cs:426-538 +CellVisibility.FindCameraCell src/AcDream.App/Rendering/CellVisibility.cs:389 (+ grace :214) +CellGraph (unified, W1) src/AcDream.Core/World/Cells/CellGraph.cs +EnvCell.FromDat (StabList/SeenOutside) src/AcDream.Core/World/Cells/EnvCell.cs:42 +``` diff --git a/docs/research/2026-06-02-retail-cell-render-study-opus48-b.md b/docs/research/2026-06-02-retail-cell-render-study-opus48-b.md new file mode 100644 index 0000000..bc9b3b0 --- /dev/null +++ b/docs/research/2026-06-02-retail-cell-render-study-opus48-b.md @@ -0,0 +1,894 @@ +# Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering + +**Study author:** Opus 4.8 (1M ctx), researcher "opus48-b" +**Date:** 2026-06-02 +**Primary oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named) +**Cross-checks:** `references/ACE/Source/ACE.Server/Physics/*`, acdream current code, `acclient.h` verbatim structs. + +> **Citation convention.** `Class::method @ 0xADDR (pc:LINE)` cites the named pseudo-C at the given +> address and line. `repo/path:LINE` cites a reference repo. **VERIFIED** = I read it in source. +> **INFER** = a reasoned conclusion not directly stated. Where ACE and the decomp agree, I say so; +> where I could not confirm something, I flag it. + +--- + +## Executive summary (read this first) + +Retail tracks "the cell I'm in" as **one** value — `SPHEREPATH::curr_cell` — that is **carried through the +collision sweep** and committed to `CPhysicsObj::cell` only when it actually changes. It is **never** +re-derived from the final resting position. The single mechanism that makes cell membership stable at +doorways is the trio **(a) accept-cell-on-successful-move** inside `validate_transition` +(`curr_cell = check_cell` only when the move was OK and the position actually changed), **(b) the +directional containing-cell picker** in `find_cell_list` (interior cells win, first interior hit breaks), +and **(c) the `do_not_load_cells` prune** that removes any candidate cell that is neither the current cell +nor in its visible/stab list. A blocked or standing-still step explicitly reverts to `curr_pos`/`curr_cell`, +so it **cannot** flip the cell. + +Rendering is **the same cell graph**. The render's "camera cell" (`SmartBox::viewer_cell`) is produced by +running a *second* transition (the camera spring-arm) and reading **its** `sphere_path.curr_cell` +(`SmartBox::update_viewer @ 0x453CE0`, pc:92871). The visible set is built by **one** portal-visibility BFS +(`PView::ConstructView`), and the outside is drawn seamlessly through a doorway because **exit portals** +(those whose `other_cell_id == 0xFFFF`) contribute a clip region to an `outside_view` that triggers +`LScape::draw` clipped to the doorway extent (`PView::ClipPortals @ 0x5A5520` pc:433662-433685; +`PView::DrawCells @ 0x5A4840` pc:432715-432719). There is **no** separate "inside / outside" stencil pass +and **no** independent render cell system in retail. + +acdream's flicker is caused by exactly the thing retail does not do: after running the (correct) swept +transition, `PhysicsEngine.ResolveWithTransition` **throws away** the swept cell (`sp.CheckCellId`/ +`sp.CurCellId`) and calls `ResolveCellId(sp.GlobalSphere[0].Origin, …)` to re-derive membership from the +final static origin (`PhysicsEngine.cs:909` and `:928`). Because collision push-back jitters that origin +±~8 cm across a cell boundary, the re-derive oscillates. **The fix is small and low-risk: return the swept +cell.** acdream already advances `sp.CurCellId = sp.CheckCellId` inside its `ValidateTransition` +(`TransitionTypes.cs:3408`) — the machinery exists; only the consumer is wrong. The render-side fix is to +root visibility at the physics cell (W2 already adds `ComputeVisibilityFromRoot`) and to draw the landscape +clipped to exit-portal regions (retail `outside_view` + `LScape::draw`). + +--- + +# A. Cell membership & transitions (physics) + +## A0. The data: how "the cell I'm in" is stored + +There are **three** distinct cell pointers, on two objects: + +**On `CPhysicsObj` (the committed, between-frames truth):** +- `CPhysicsObj::cell` — the object's *committed* current cell. `acclient.h` (the object is large; `cell` is + the resident cell pointer set by `enter_cell`/`leave_cell`). +- `CPhysicsObj::m_position.objcell_id` — the committed cell **id** (low 16 bits = cell-within-landblock, + high 16 = landblock). + +**On `SPHEREPATH` (the per-transition working state):** `SPHEREPATH` struct verbatim +(`acclient.h:32625-32671`, VERIFIED): +```c +struct SPHEREPATH { + unsigned int num_sphere; + CSphere *local_sphere; ... CSphere *global_sphere; ... + AC1Legacy::Vector3 *global_curr_center; // current sphere center (advances per sub-step) + ... + CObjCell *begin_cell; Position *begin_pos; Position *end_pos; + CObjCell *curr_cell; Position curr_pos; // the ACCEPTED cell + pos so far + AC1Legacy::Vector3 global_offset; + int step_up; ... int collide; + CObjCell *check_cell; Position check_pos; // the CANDIDATE cell + pos being tested + SPHEREPATH::InsertType insert_type; + int step_down; ... + CObjCell *backup_cell; Position backup_check_pos; + int obstruction_ethereal; + int hits_interior_cell; // set when the candidate set touches an EnvCell + int bldg_check; + ... + int cell_array_valid; // is the cached CELLARRAY still good for this check_pos? + ... +}; +``` + +The mental model: **`curr_cell` is "where I have validly reached so far"; `check_cell` is "the cell of the +position I'm trying next."** The transition advances `check_*`, tests it, and on success promotes it into +`curr_*`. At the very end, the *committed* `CPhysicsObj::cell` is synced from `sphere_path.curr_cell`. + +`CELLARRAY` (the collision candidate set) is a fourth thing, separate from `curr_cell` — see A5. +`CELLARRAY` verbatim (`acclient.h:31574-31580`, VERIFIED): +```c +struct CELLARRAY { + int added_outside; // guards add_all_outside_cells (add outdoors once per build) + int do_not_load_cells; // the prune flag (see A2) + unsigned int num_cells; + DArray cells; // CELLINFO = { uint cell_id; CObjCell* cell; } (acclient.h:31925) +}; +``` + +## A1. The full update chain (per physics tick) + +I traced the chain end-to-end. **VERIFIED** at every step: + +``` +CPhysicsObj::UpdateObjectInternal (per-tick body, ~pc:283600+) + └─ UpdatePositionInternal @ 0x512C30 (pc:280817) // compute desired Frame offset + └─ eax_10 = CPhysicsObj::transition(this, m_position, dest, 0) @ 0x512DC0 (pc:280904) + │ └─ CTransition::init_path(result, this->cell, begin, end) @ 0x509E60 (pc:271982) + │ │ └─ SPHEREPATH::init_path @ 0x50CE20 (pc:274359): + │ │ curr_cell = begin_cell = this->cell; curr_pos = begin_pos; // SEED + │ └─ CTransition::find_valid_position @ 0x50C310 (pc:273890) + │ └─ (TRANSITION_INSERT) CTransition::find_transitional_position @ 0x50BDF0 (pc:273613) + │ └─ FOR each of var_48 sub-steps: + │ check_pos += global_offset // advance candidate + │ var_44 = validate_transition(transitional_insert(this,3), &redo) + │ ▲ transitional_insert @ 0x50B6F0 (pc:273137) // the stepper + │ ▲ validate_transition @ 0x50AA70 (pc:272547) // accept/advance + └─ if (eax_10 != 0) CPhysicsObj::SetPositionInternal(this, eax_10) @ 0x515330 (pc:283696) + └─ curr_cell = arg2->sphere_path.curr_cell; // READ the swept cell + └─ if (this->cell != curr_cell) change_cell(this, curr_cell); // COMMIT only on change + └─ set_frame(this, &arg2->sphere_path.curr_pos.frame); // commit position +``` + +The per-tick body at pc:283673 (VERIFIED): `class CTransition* eax_10 = CPhysicsObj::transition(this, +&this->m_position, &var_48, 0);` then pc:283696: `CPhysicsObj::SetPositionInternal(this, eax_10);`. **The +cell that ends up on the object is read straight out of the transition's `sphere_path.curr_cell`. No static +re-derive is performed anywhere in this chain.** + +### A1.1 `transitional_insert` — the sub-step stepper + +`CTransition::transitional_insert @ 0x50B6F0` (pc:273137, VERIFIED). For up to `arg2` insertion attempts it: +1. `edi = insert_into_cell(this, sphere_path.check_cell, arg2)` (pc:273153) — collide the candidate sphere + against `check_cell`'s BSP. +2. On `OK_TS`: `edi = check_other_cells(this, sphere_path.check_cell)` (pc:273161) — test every *other* cell + the sphere overlaps (via `find_cell_list`) and **retarget `check_cell` to the containing cell**. +3. Switch on the state: `COLLIDED_TS` returns (blocked); `ADJUSTED_TS`/`SLID_TS` clear `neg_poly_hit` and + continue; on `OK_TS` it handles step-down / edge-slide / slide-sphere. + +Key: `check_other_cells` is where, mid-sweep, the **candidate cell is reassigned** to the cell that +actually contains the swept sphere center. So as the sphere crosses a portal during the sweep, `check_cell` +follows it cell-by-cell. + +### A1.2 `validate_transition` — accept the move and advance `curr_cell` + +`CTransition::validate_transition @ 0x50AA70` (pc:272547, VERIFIED). This is the linchpin. Structure: + +```c +result = arg2; // the TransitionState from transitional_insert +if (result != OK_TS) { // ── blocked / slid / adjusted ── + if (result in (OK_TS, SLID_TS]) { // collided/adjusted/slid + ... restore last-known contact plane, kill velocity ... + set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // REVERT (pc:272593) + build_cell_array(this, nullptr); + result = OK_TS; + } +} else { // ── OK ── + if (check_pos.objcell_id == curr_pos.objcell_id // (same cell && + && Frame::is_equal(check_pos.frame, curr_pos.frame)) // same frame) → no movement + goto done; // accept as-is, do NOT advance + // else: real movement → PROMOTE check → curr: + label_50aba9: + curr_pos.objcell_id = check_pos.objcell_id; // (pc:272610) + curr_pos.frame = check_pos.frame; + curr_cell = check_cell; // *** ADVANCE MEMBERSHIP *** (pc:272612) + cache_global_curr_center(&sphere_path); + // reset check_* = curr_* for next sub-step: + check_pos.objcell_id = curr_pos.objcell_id; + check_pos.frame = curr_pos.frame; + check_cell = curr_cell; +} +``` + +**The two guarantees that kill flicker live here, VERIFIED:** +- **Blocked/slid path (pc:272593):** `set_check_pos(curr_pos, curr_cell)` — the candidate is thrown away + and reset to the *current* (last-accepted) position/cell. A wall bump does **not** change `curr_cell`. +- **OK-but-didn't-move path (pc:272600-272605):** if `check_pos == curr_pos` (same id and frame), + `goto done` — **no promotion**. Standing still does **not** change `curr_cell`. +- **OK-and-moved path (pc:272608-272619):** *only here* is `curr_cell = check_cell` executed. + +**ACE cross-check (agrees exactly):** `Transition.ValidateTransition` (ACE `Transition.cs:984`). +On `transitionState != OK` and not `Invalid`, it calls `SpherePath.SetCheckPos(SpherePath.CurPos, +SpherePath.CurCell)` (ACE `Transition.cs:1014`) — revert. On `OK`, `SetCurrentCheckPos()` (ACE +`Transition.cs:1084-1091`) does `SpherePath.CurPos = CheckPos; SpherePath.CurCell = SpherePath.CheckCell;` +— advance. The gate is `transitionState != OK || CheckPos.Equals(CurPos)` (ACE `Transition.cs:990`). Same +logic, same membership advance. + +### A1.3 `SetPositionInternal(CTransition)` — commit, only on change + +`CPhysicsObj::SetPositionInternal @ 0x515330` (pc:283399, VERIFIED): +```c +curr_cell = arg2->sphere_path.curr_cell; // (pc:283403) +if (curr_cell == 0) { ... GotoLostCell ... } // left the world +else { + if (this->cell == curr_cell) { // SAME cell → just refresh ids (pc:283414) + this->m_position.objcell_id = sphere_path.curr_pos.objcell_id; + ... SetCellID on parts/children ... + } else + CPhysicsObj::change_cell(this, curr_cell); // DIFFERENT cell → leave+enter (pc:283456) + CPhysicsObj::set_frame(this, &sphere_path.curr_pos.frame); + ... copy contact_plane, transient_state from transition ... +} +``` + +**`change_cell` only fires when `this->cell != curr_cell`.** Since `curr_cell` came from `validate_transition` +(stable across blocks/standing-still), the committed cell is stable too. + +`CPhysicsObj::change_cell @ 0x513390` (pc:281192, VERIFIED): `if (this->cell) leave_cell(this,1); +if (arg2) enter_cell(this, arg2); else { m_position.objcell_id = 0; cell = null; }`. `leave_cell`/`enter_cell` +manage the cell's `shadow_object_list`/`object_list` membership and part-array cell ids. + +## A2. `find_cell_list` — building the candidate array & picking the containing cell + +`CObjCell::find_cell_list` has several overloads. The one used everywhere through the sweep is the +3-arg forwarder `find_cell_list(CELLARRAY*, CObjCell** out, SPHEREPATH*) @ 0x52B960` (pc:309085) which +forwards to the master overload `find_cell_list(Position, num_sphere, CSphere, CELLARRAY, CObjCell** out, +SPHEREPATH) @ 0x52B4E0` (pc:308742), passing `check_pos`, `num_sphere`, `global_sphere`. + +**Master overload, VERIFIED (pc:308742-308869). Annotated:** +```c +edi = arg4; // CELLARRAY +edi->num_cells = 0; +edi->added_outside = 0; +objcell_id = arg1->objcell_id; // the position's current cell id +visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // indoor + : CLandCell::GetVisible(objcell_id); // outdoor + +// (1) seed the array with the current cell (indoor) or the outdoor landcells: +if (objcell_id >= 0x100) { // INDOOR + if (arg6) arg6->hits_interior_cell = 1; + CELLARRAY::add_cell(edi, objcell_id, visibleCell); +} else // OUTDOOR + CLandCell::add_all_outside_cells(arg1, num_sphere, sphere, edi); // (pc:308769) + +if (visibleCell != 0 && num_sphere != 0) { + // (2) EXPAND: each cell contributes its transit neighbors (portals / building portals / outside): + for (i in 0..num_cells) + edi->cells[i].cell->vtable->find_transit_cells(arg1, num_sphere, sphere, edi, arg6); // +0x80 + + // (3) PICK the single containing cell into *arg5: + if (arg5) { + *arg5 = null; + for (i in 0..num_cells) { + cell = edi->cells[i].cell; + blockOffset = LandDefs::get_block_offset(arg1->objcell_id, cell.id); + localCenter = sphere.center - blockOffset; + if (cell->vtable->point_in_cell(&localCenter)) { // +0x84 + *arg5 = cell; + if ((cell.id & 0xFFFF) >= 0x100) { // INTERIOR cell wins: + if (arg6) arg6->hits_interior_cell = 1; + break; // *** first interior hit wins *** + } + // outdoor hit: keep scanning (an interior cell may still contain the point) + } + } + } + + // (4) PRUNE (do_not_load_cells), only when currently in an interior cell: + if (edi->do_not_load_cells && (arg1->objcell_id & 0xFFFF) >= 0x100) { + for (i in 0..num_cells) { + cell_id = edi->cells[i].cell_id; + if (cell_id == visibleCell->m_DID.id) continue; // keep the current cell + found = false; + for (stab in visibleCell->stab_list[0..num_stabs]) if (cell_id == stab) { found=true; break; } + if (!found) CELLARRAY::remove_cell(edi, i); // drop "stranger" cells + } + } +} +``` + +The `arg4 + 0x28` / `arg4 + 0xe0`/`+0xe4` field offsets in the raw decomp (pc:308839, 308846, 308851) resolve +to `visibleCell->m_DID.id` and `visibleCell->num_stabs`/`visibleCell->stab_list` — confirmed by +`CObjCell` layout (`acclient.h:30927-30928`: `unsigned int num_stabs; unsigned int *stab_list;`). + +**ACE cross-check (agrees exactly):** `ObjCell.find_cell_list` (ACE `ObjCell.cs:335-414`). +- Picker breaks on first interior cell containing the point (ACE `ObjCell.cs:378-382`). +- Prune: `if (!cellArray.LoadCells && (position.ObjCellID & 0xFFFF) >= 0x100)` removes any cell that is + neither `visibleCell.ID` nor in `((EnvCell)visibleCell).VisibleCells` (ACE `ObjCell.cs:387-413`). + (ACE inverts the name: `LoadCells == !do_not_load_cells`.) + +### A2.1 What `do_not_load_cells` is, when it's set, what it buys + +**What it is:** a flag on the `CELLARRAY` that, when set, restricts the candidate cell set to *(the current +cell)* ∪ *(its visible/stab list)*. The "stab list" of a `CEnvCell` is the set of cell ids the dat marks as +visible/reachable from that cell (`CObjCell::stab_list`, also driving `find_visible_child_cell` and the +render's `add_views`). Outdoor landcells are **never** in an interior cell's stab list, so the prune drops +them. + +**When it's set:** `CPhysicsObj::SetPositionInternal(Position, SetPositionStruct, CTransition) @ 0x515BD0` +(pc:283929-283930, VERIFIED): `if ((arg3->flags & 0x20) != 0) edi->cell_array.do_not_load_cells = 1;`. +i.e., it's a per-call option keyed on `SetPositionStruct` flag `0x20`. This flag is set by callers that move +the object **without** wanting new cells streamed in / without crossing out of the known cell set — most +relevantly authoritative position teleports and constrained sets where the server already told us the cell. +**INFER (medium confidence):** during ordinary frame movement (`CPhysicsObj::transition`) the flag is *not* +set, so the prune does not run on every walk-tick; it's specifically a stability guard for set-position +operations. The flicker-killing for ordinary walking comes from A1.2's accept-on-move + A2's directional +picker, *not* from the prune. The prune's stability value is: when you ask "which cell is this position in?" +during a constrained set, you never accidentally promote into an outdoor landcell or a far interior cell +just because the foot sphere clipped its bounding volume. + +**INFER:** acdream's analogue would set this for server `UpdatePosition` and any "snap to known cell" path, +not for free movement. (acdream currently has *no* `do_not_load_cells` — it instead bolts a `DoorwayHoldMargin` +hysteresis onto the static re-derive; see D.) + +## A3. Precisely how retail avoids cell flicker (the answer) + +It is a **combination**, with the dominant mechanism being **swept-path containment with accept-on-move**: + +1. **Membership is carried, not re-derived.** `curr_cell` persists across ticks via `CPhysicsObj::cell` and + is only ever changed inside `validate_transition` on a *successful, position-changing* sub-step + (pc:272612). A tick that ends blocked or standing-still leaves `curr_cell` exactly where it was + (pc:272593, pc:272600-272605). **This is the property acdream lacks** — acdream recomputes from the + static origin every tick. + +2. **The picker is directional/priority-ordered.** When the candidate set *is* rebuilt (mid-sweep via + `check_other_cells`, or on a `do_not_load_cells` set), `find_cell_list` breaks on the **first interior + cell** that contains the point (pc:308814-308819). Interior cells dominate outdoor cells. So at the + threshold, as long as the foot sphere's center is inside the vestibule's `cell_bsp`, the vestibule wins + even though the outdoor landcell also overlaps the sphere. + +3. **`point_in_cell` is a precise BSP/leaf test, not a bounding-box test.** `CEnvCell::point_in_cell @ + 0x52C300` (pc:309677, VERIFIED): transforms the global point into the cell's local frame + (`Frame::globaltolocal`) then `CCellStruct::point_in_cell(structure, localPoint)` — a test against the + cell's `cell_bsp` leaf volume (`CCellStruct.cell_bsp`, `acclient.h:32289`). `CLandCell::point_in_cell @ + 0x52D40` (pc:316941) tests `find_terrain_poly` — the point is in the landcell iff a terrain triangle + contains it. Because `point_in_cell` is exact, the "containing cell" is unambiguous for a given center. + +4. **The `do_not_load_cells` prune** (A2.1) is the *additional* guard for set-position; it removes + stranger cells from the candidate array so a constrained set cannot drift the cell. + +The flicker acdream sees (`0xA9B40170 ↔ 0xA9B40031` at a static position) is structurally impossible in +retail: retail would have committed `curr_cell = 0xA9B40170` once (when the sweep that crossed the doorway +succeeded), and every subsequent standing-still tick hits `validate_transition`'s "didn't move → don't +promote" branch (pc:272600-272605), so the cell never re-evaluates against the jittered origin at all. + +## A4. Transitions: indoor↔outdoor, interior↔interior; `CCellPortal` vs `CBldPortal` + +Two portal types, two directions: + +### A4.1 `CCellPortal` (interior↔interior, and interior→exterior) + +`CCellPortal` verbatim (`acclient.h:32300-32308`, VERIFIED): +```c +struct CCellPortal { + unsigned int other_cell_id; // 0xFFFFFFFF (==0xFFFF low) → EXTERIOR portal (leads outside) + CEnvCell *other_cell_ptr; // resolved neighbor (or null) + CPolygon *portal; // the portal polygon (its plane = the doorway plane) + int portal_side; // which half-space is "inside" + int other_portal_id; + int exact_match; +}; +``` + +`CCellPortal::GetOtherCell @ 0x53BA30` (pc:324830, VERIFIED) = `CEnvCell::GetVisible(other_cell_id)`. + +**Interior→interior expansion** is in `CEnvCell::find_transit_cells @ 0x52C820` (pc:309968, VERIFIED): for +each of the cell's `portals[]`: +- `other = CCellPortal::GetOtherCell(portal)`. If non-null and the sphere intersects `other->structure` + (`CCellStruct::sphere_intersects_cell != OUTSIDE`, pc:310052), `CELLARRAY::add_cell(other)` (pc:310054). +- If `other == null` (an **exterior** portal, `other_cell_id == 0xFFFF`), it instead does a plane-distance + test of the sphere against the portal poly; if the sphere is on/through the portal it sets a local flag + `var_44` (pc:310099). After processing all portals, `if (var_44) CLandCell::add_all_outside_cells(...)` + (pc:310119-310120) — **this is how the outdoor landcells enter the physics candidate set when the player + is at/through an exit doorway**, so collision against outdoor terrain works at the threshold. + +**ACE cross-check:** `EnvCell.find_transit_cells` (ACE `EnvCell.cs:311-370`) — same: portal loop, sphere +intersect test, and `LandCell.add_all_outside_cells` at the end (ACE `EnvCell.cs:370`). + +### A4.2 `CBldPortal` (exterior→interior building entry) + +`CBldPortal` verbatim (`acclient.h:32094-32103`, VERIFIED): +```c +struct CBldPortal { + int portal_side; + unsigned int other_cell_id; // the interior EnvCell this building portal leads into + int other_portal_id; + int exact_match; + unsigned int num_stabs; + unsigned int *stab_list; + float sidedness; +}; +``` + +When the player is in an **outdoor** landcell, the landcell's `CSortCell` may hold a `CBuildingObj`. +`CLandCell::find_transit_cells @ 0x533800` (pc:317603, VERIFIED): `add_all_outside_cells(...)` then +`CSortCell::find_transit_cells(...)` (pc:317607) → `CBuildingObj::find_building_transit_cells @ 0x6B5230` +(pc:701214, VERIFIED): for each building portal, `other = CBldPortal::GetOtherCell(portal)` +(`= CEnvCell::GetVisible(other_cell_id)`, pc:325003), and if non-null, +`CEnvCell::check_building_transit(other, portal->other_portal_id, ...)` (pc:701227). + +`CEnvCell::check_building_transit @ 0x52C5D0` (pc:309827, VERIFIED): if the sphere intersects the interior +cell's `structure` (`sphere_intersects_cell != OUTSIDE`), it `add_cell`s the interior EnvCell and sets +`sphere_path->hits_interior_cell = 1` (pc:309857-309860). **This is the outdoor→indoor entry**: standing +outside, when your foot sphere pokes through a building's door portal, the interior cell joins the candidate +set, the directional picker (A3.2) prefers it (interior wins), and `curr_cell` advances into the building on +the next successful sub-step. + +`CSortCell : CObjCell { CBuildingObj* building }` (`acclient.h:31880-31883`); `CBuildingObj : CPhysicsObj +{ num_portals; CBldPortal** portals; num_leaves; CPartCell** leaf_cells; ... }` (`acclient.h:31908-31916`). + +### A4.3 indoor→outdoor (exit) resolution at set-position time + +`CPhysicsObj::AdjustPosition @ 0x511D80` (pc:280009, VERIFIED) is the *initial* cell resolver used by +`SetPositionInternal(Position,…)`. For an indoor id it: +1. `eax_5 = CObjCell::GetVisible(objcell_id)`. +2. `eax_6 = CEnvCell::find_visible_child_cell(eax_5, globalPoint, arg5)` (pc:280028) — find the exact child + cell containing the point (via stab list or portals). +3. If found → use it (pc:280032). +4. If **not** found AND `eax_5->seen_outside != 0` (pc:280037) → `Position::adjust_to_outside(arg1)` + (pc:280039) and `GetVisible(outsideId)` — **the indoor→outdoor exit**: when the point is no longer in any + reachable interior child cell and the cell can see outside, convert to the outdoor landcell. + +`CObjCell::seen_outside` (`acclient.h:30929`, VERIFIED) is the per-cell flag "this cell has an exterior +portal / can reach the open world." + +`check_other_cells` has the mid-sweep version of the same exit (pc:272772-272795): when no candidate cell +contains the swept center and the id < 0x100 path applies, it calls `LandDefs::adjust_to_outside` and resets +`check_cell = null` with the outdoor id, letting the next sub-step land in the outdoor landcell. + +## A5. Is the cell ARRAY the same as `curr_cell`? — No, they're two things, related per-transition + +- **`curr_cell` (and committed `CPhysicsObj::cell`)** = *membership* — the single answer to "which cell am I + in." One pointer. Advanced only by `validate_transition`. +- **`CELLARRAY` (`CTransition::cell_array`)** = *the collision candidate set* — every cell whose BSP/geometry + the swept sphere must be tested against this sub-step (the current cell + portal neighbors + outdoor + landcells if a doorway is straddled + building interiors if a building portal is straddled). Many cells. + Rebuilt by `find_cell_list` each time `cell_array_valid == 0`. + +**How they relate within one transition:** `find_cell_list` does both jobs in one pass — it fills the +`CELLARRAY` (for collision) **and** writes the single containing cell into `*arg5` (the membership candidate). +`check_other_cells @ 0x50AE50` (pc:272717, VERIFIED) calls `find_cell_list(cell_array, &var_4c, sphere_path)`, +collides the sphere against every array cell except the current one (`cell->vtable[+0x88](this)` = +`find_collisions`, pc:272735), and on success sets `sphere_path.check_cell = var_4c` (the containing cell, +pc:272760-272761). So: **the array drives collision; the picked element (`var_4c`) becomes the next +`check_cell`, which `validate_transition` then promotes to `curr_cell`.** Two mechanisms, one shared builder. + +--- + +# B. Underground / dungeons + +## B6. Representation: dungeons vs building interiors + +Both dungeons and building interiors are **EnvCell graphs** (`CEnvCell` with `structure`, `portals`, +`static_objects`), but they differ in their relationship to the landblock and terrain: + +- **Building interior (cottage/inn):** the EnvCells sit *on* a landblock that has terrain. They are reached + from the open world via a `CBuildingObj`'s `CBldPortal`s (A4.2). Some of their `CCellPortal`s are + **exterior portals** (`other_cell_id == 0xFFFF`) — the doorways/windows that see the outdoors. `seen_outside` + is **true** for cells with such portals. The landblock's `CLandBlockInfo` (`acclient.h:31893-31905`, + VERIFIED) carries `num_cells; cell_ids; CEnvCell** cells;` (the interior cells) **and** + `num_buildings; BuildInfo** buildings;` (the buildings) alongside `cell_ownership` and a + `restriction_table`. + +- **Dungeon:** a self-contained EnvCell graph (often its own landblock with the `0x..FF` "all-cells" range) + with **no exterior portals** and **no terrain** (`CLandCell` for that landblock is degenerate). `seen_outside` + is **false** for dungeon cells. INFER (high confidence): the engine "knows there's no sky/terrain" not via a + dedicated underground flag but because the camera cell is an `CEnvCell` whose reachable graph contains **no + exit portal** — so `PView`'s `outside_view.view_count` stays 0 and `LScape::draw` is never invoked through a + portal (see C). The dat-level distinction is the **absence of exterior portals / `seen_outside == 0`**, not a + boolean "underground." + +`CCellStruct` (the per-cell geometry, `acclient.h:32275-32290`, VERIFIED) carries everything a cell needs: +`vertex_array; num_portals; CPolygon** portals; surface_strips; polygons; drawing_bsp; +physics_polygons; physics_bsp; cell_bsp;`. Note the **three BSPs**: `drawing_bsp` (render), `physics_bsp` +(collision against cell geometry), `cell_bsp` (point/sphere-in-cell containment tests). A dungeon cell and a +building interior cell are the *same* struct; only their portal topology and `seen_outside` differ. + +## B7. Moving through a dungeon: cell tracking, loading, no-terrain + +- **Cell tracking** is identical to A1-A3: `transition` → `validate_transition` advances `curr_cell` across + `CCellPortal`s (interior→interior, A4.1). The only difference is that no portal is an exterior portal, so + `find_transit_cells` never calls `add_all_outside_cells` (its `var_44` flag stays 0). +- **Loading/streaming:** `CEnvCell::GetVisible @ 0x52DC10` (pc:311378, VERIFIED) and `CObjCell::GetVisible @ + 0x52AD40` (pc:308209, dispatch by id magnitude: ≥0x100 → `CEnvCell::GetVisible`, else + `CLandCell::GetVisible`). EnvCells are fetched/built on demand; `CEnvCell::PreFetchCells @ 0x52C460` + (pc:309754, VERIFIED) prefetches the cell's `stab_list`-reachable cells **and**, *only if* `seen_outside`, + the surrounding landblock (`LScape::PreFetchCells(m_DID.id | 0xFFFF)`, pc:309759). For a dungeon + (`seen_outside == 0`) the surrounding landscape is **never prefetched** — confirming there is no terrain to + stream/draw. +- **No-sky/terrain knowledge:** see B8 + C12. + +## B8. Is there an explicit "underground" flag? + +**Mostly no — it's derived.** I found **no** boolean `is_underground` on `Position`, landblock, or cell. The +operative field is `CObjCell::seen_outside` (`acclient.h:30929`, VERIFIED). The render decision (C12) keys on +*"is the viewer cell an `CEnvCell`, and does it / its reachable graph have an exterior portal?"*: +- `SmartBox::RenderNormalMode @ 0x453AA0` (pc:92649, VERIFIED) computes + `ebx_1 = (outdoor_view || viewer_cell->seen_outside)` to decide whether to update the landscape viewpoint + at all. +- `PView` accumulates exterior-portal clip regions into `outside_view`; if `outside_view.view_count == 0` + (no exit portal was visible — i.e., a sealed dungeon), `LScape::draw` is skipped in `DrawCells` + (pc:432715, VERIFIED). So "underground" ≡ "current `CEnvCell` reachable graph yields no visible exterior + portal," which makes terrain+sky drop out naturally. + +There is also the cell-id magnitude convention itself: low-16 `>= 0x100` ⇒ this id names an `CEnvCell` +(interior), `< 0x100` ⇒ a `CLandCell` (outdoor surface cell of a landblock). This is the *type* discriminator +used everywhere (`find_cell_list` pc:308753, `GetVisible` pc:308209), but it does **not** by itself mean +"underground" — a cottage interior is `>= 0x100` too. Underground is the further refinement +`seen_outside == 0`. + +--- + +# C. Rendering inside and outside (the seamless seal) + +## C9. The single-pass visible-set build (`ConstructView` / `InitCell` / PView) + +Retail's interior render is `PView` ("portal view"). The whole thing is **one** portal-flood BFS over the +shared `CEnvCell` graph. Top-level entry when the camera is inside a cell: + +`PView::DrawInside @ 0x5A5860` (pc:433793, VERIFIED): +```c +CEnvCell::curr_view_push(arg2); // push this cell's view stack +PView::add_views(this, arg2->num_stabs, arg2->stab_list); // pre-push stab-list cells (pc:433801) +Render::copy_view(arg2->portal_view.data[num_view-1], null, 4); // seed the camera's view +edx_2 = PView::ConstructView(this, arg2, 0xFFFF); // *** build visible set *** +PView::DrawCells(this, edx_2); // *** draw it *** +PView::remove_views(this, arg2->num_stabs, arg2->stab_list); +arg2->num_view -= 1; +``` + +`PView::ConstructView(CEnvCell, portal_id) @ 0x5A57B0` (pc:433750, VERIFIED) — the BFS: +```c +outside_view.view_count = 0; // reset the "outside seen through a portal" accumulator +master_timestamp += 1; +cell_todo_num = 0; cell_draw_num = 0; +InitCell(this, arg2, portal_id); // compute per-portal in/out flags for the start cell +InsCellTodoList(this, arg2, 0); // seed the worklist +while (cell_todo_num > 0) { + cell = pop(cell_todo_list); + if (cell == 0) break; + cell_draw_list[cell_draw_num++] = cell; // add to OUTPUT + cell->portal_view[num_view-1]->cell_view_done = 1; + if (ClipPortals(this, cell, 0)) // clip this cell's portals to the view + AddViewToPortals(this, cell); // enqueue visible neighbor cells +} +``` + +`PView::InitCell @ 0x5A4B70` (pc:432896, VERIFIED): for each portal of the cell, it computes the portal +plane's side relative to the camera viewpoint (`Render::FrameCurrent->viewer.viewpoint`, pc:432935-432962), +sets the portal's `seen`/`inflag` state in `portal_view`, and chooses the relevant `portal_side`. This is the +per-portal visibility/side determination. + +`PView::AddViewToPortals @ 0x5A52D0` (pc:433446, VERIFIED): walks the cell's portals; for each portal whose +`other_cell` exists and is flagged visible, it `InitCell`s the neighbor and `InsCellTodoList`s it +(pc:433480-433485) — enqueuing the neighbor into the BFS — and `SetOtherSeen` (pc:433490). This is the +recursive portal traversal: visibility flows cell→neighbor only through portals the camera can see through. + +**Output:** `cell_draw_list[0..cell_draw_num]` = the ordered list of visible `CEnvCell`s, each with a +per-portal **clip region** stored in its `portal_view` (`CEnvCell.num_view` / `portal_view`, +`acclient.h:32089-32090`), plus `outside_view` = the accumulated exterior-portal clip region(s). + +**acdream parallel (already present):** `CellVisibility.GetVisibleCellsFromRoot` (`CellVisibility.cs:539`) +is the same portal BFS — a `Queue`, per-portal `InsideSide`/clip-plane test (`CellVisibility.cs:577-589`, +"Source: ACME EnvCellManager.cs lines 1458-1459"), exit-portal detection (`portal.OtherCellId == 0xFFFF → +HasExitPortalVisible = true`, `CellVisibility.cs:561-565`). So acdream's render already mirrors retail's +`ConstructView`; what's missing is consuming the result correctly + drawing the outside through the portal +(C10) and rooting it at the physics cell (C13/D). + +## C10. Drawing the OUTSIDE through a doorway/window (no blue clear-color hole) + +This is the crux. Two pieces: + +**(1) Exit portals contribute a clip region to `outside_view`.** Inside `PView::ClipPortals @ 0x5A5520` +(pc:433572, VERIFIED), when iterating a cell's portals, the branch at pc:433662-433685 handles a portal whose +`other_cell` id is `0xFFFFFFFF` (an exterior portal): +```c +if (*esi_3 == 0xFFFFFFFF) { // EXTERIOR portal + if (this->draw_landscape != 0) { // PView built with draw_landscape=true + if (cliplandscape != 0) Render::copy_view(this/*->outside_view*/, &clip_view, ecx_8); + else if (draw_landscape) Render::copy_view(this/*->outside_view*/, null, 0); + } +} +``` +i.e., the exterior portal's **screen clip region** (`clip_view`, computed by `GetClip`) is copied into the +PView's `outside_view`. The `draw_landscape` flag is set at PView construction (`PView::PView @ 0x5A5270` +pc:433441: `this->draw_landscape = arg2;`, VERIFIED) — the *indoor* PView is built with +`draw_landscape = true` so doorways always feed the landscape view. + +**(2) `DrawCells` renders the landscape clipped to that region.** `PView::DrawCells @ 0x5A4840` +(pc:432709, VERIFIED) opens with: +```c +if (this->outside_view.view_count > 0) { // an exit portal was visible + Render::useSunlightSet(1); + Render::PortalList = this; // tell LScape to clip to outside_view + LScape::draw(this->lscape); // *** draw terrain + sky + exterior, clipped *** + D3DPolyRender::FlushAlphaList(0); + ... + if (forceClear || portalsDrawnCount==0) // clear-color ONLY if nothing was drawn + RenderDevice::Clear(4, 0x820fc0, ...); // (pc:432731-432732) + ... draw interior cells' surfaces (drawing_bsp), then portals ... +} +``` + +So the outdoors (terrain, sky, rain, exterior buildings) is drawn by `LScape::draw @ 0x506330` (pc, VERIFIED +address) **with `Render::PortalList` set to the PView**, which clips it to the union of exit-portal screen +regions. The result: through a cottage doorway you see the actual world (sky/rain), not a clear-color hole. +**The blue clear-color only appears if `portalsDrawnCount == 0`** — i.e., if the portal machinery produced +nothing (a truly sealed cell, or a bug). + +**Positioning the outside correctly:** before `DrawInside`, `RenderNormalMode` (pc:92667-92670, VERIFIED) +does `if (ebx_1 /*seen_outside*/) { eax_1 = Position::get_outside_cell_id(&viewer); +LScape::update_viewpoint(lscape, eax_1); }`. `Position::get_outside_cell_id @ 0x4527B0` (pc:91552, VERIFIED) +converts the indoor camera position to the outdoor landcell id via `LandDefs::adjust_to_outside`. So the +landscape is centered on the landblock the building sits in, ready to be drawn through the doorway. + +`PView::GetClip @ 0x5A4320` (pc:432344, VERIFIED) is the clip-region builder: it projects the portal poly's +vertices to screen (`PrimD3DRender::xformStart`) and runs `ACRender::polyClipFinish` to produce the 2D clip +polygon, honoring `Sidedness` (front/back of the portal). + +**The exterior→interior recursion (camera OUTSIDE looking into a building):** `PView::ConstructView(CBldPortal, +CPolygon portal, …) @ 0x5A59A0` (pc:433827, VERIFIED) is the mirror image — reached via `PView::DrawPortal @ +0x5A5AB0` (pc:433895) while drawing the landscape. It side-tests the building portal poly against the camera, +`GetClip`s it, and if the interior is visible recurses `ConstructView(this, other_cell, other_portal_id)` +(pc:433879) to draw the building's interior cells through the open door, clipped to the door's screen region. +So **both directions are the same portal mechanism**: outside↔inside is seamless because it's literally one +recursive portal-clipped traversal across the shared cell graph. + +## C11. Sealing interiors (ceilings capped, no bleed, entities clipped) + +- **Walls/ceilings are capped because each visible cell draws its own closed geometry.** `DrawCells` + (pc:432745-432802, VERIFIED) draws each `cell_draw_list` cell's surfaces using + `cell->structure->drawing_bsp` and `Render::SetSurfaceArray(cell->surfaces)`, per portal-view + (`CEnvCell::setup_view` per `view_count`). An EnvCell's geometry is a closed box (floor, 4 walls, ceiling) + authored in the dat; the `drawing_bsp` orders/back-face-culls it. There is no "open top" — the ceiling + polygon is part of the cell's surface array. So standing inside, the ceiling is present by construction. +- **No outdoor bleed-in** because the outdoor world is only drawn *through* exit-portal clip regions + (`outside_view`), never full-screen, when the camera cell is interior. The interior cells are drawn after / + composited with the clipped landscape. The `Clear(4,…)` (depth/region clear) only fires where nothing was + drawn. +- **Entities/particles clipped to visible cells:** the final loop of `DrawCells` (pc:432868-432882, + VERIFIED) iterates `cell_draw_list` and for each calls `DrawObjCellForDummies(cell)` with + `Render::PortalList` set to that cell's portal view — i.e., objects are drawn per-cell, clipped to that + cell's visible portal region. An object in a non-visible cell is never in `cell_draw_list`, so it isn't + drawn; an object straddling a portal is clipped to the portal opening. (Object→cell membership comes from + the physics `enter_cell`/`leave_cell` shadow lists — the *same* cell graph; see C13.) + +## C12. Terrain + sky vs not, as a function of current cell + +The decision tree (`SmartBox::RenderNormalMode @ 0x453AA0`, pc:92635-92684, VERIFIED), per frame: + +``` +viewer_cell = SmartBox::update_viewer's result (see C13) +outdoor_view = (viewer_cell is a LandCell / id < 0x100, OR static_camera special-case) // "edi_2" +ebx_1 = outdoor_view || viewer_cell->seen_outside + +if (outdoor_view) { // camera is OUTSIDE + LScape::update_viewpoint(lscape, viewer.objcell_id); + Render::update_viewpoint(&viewer); + Render::set_default_view(); + Render::useSunlightSet(1); + LScape::draw(lscape); // FULL terrain + sky (+ recurse into buildings) +} else { // camera is INSIDE an EnvCell + if (ebx_1 /*seen_outside*/) // interior that can see out: + LScape::update_viewpoint(lscape, Position::get_outside_cell_id(&viewer)); // pre-position terrain + Render::update_viewpoint(&viewer); + RenderDevice::DrawInside(viewer_cell); // PView portal traversal; terrain only through exits +} +``` + +So: +- **Outdoor cell (`< 0x100`):** full landscape + sky drawn unconditionally (`LScape::draw`). Buildings are + recursed into via `CBldPortal` portals during the landscape draw. +- **Interior cell with `seen_outside`** (cottage/inn): `DrawInside` (interior cells), and the landscape is + drawn **only** through visible exit portals (C10). Sky/rain appears in the doorway, not full-screen. +- **Interior cell without `seen_outside`** (dungeon): `DrawInside`, `outside_view.view_count` stays 0, + `LScape::draw` is never reached, so **no terrain, no sky** — exactly what a dungeon needs. + +`RenderDeviceD3D::DrawInside @ 0x59F0D0` (pc:427843, VERIFIED) just forwards: +`PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2)`. + +## C13. Is render's cell the SAME as physics's `curr_cell`? — YES (this is the central finding) + +**VERIFIED, conclusively.** The render's camera cell is produced by `SmartBox::update_viewer @ 0x453CE0` +(pc:92761), which: +1. Starts from `player->cell` (the physics-committed cell, pc:92836/92842 — `cell = this->player->cell`). +2. Builds a *camera* transition (`CTransition::makeTransition` + `init_object(player, 0x5c)` + + `init_sphere(1, &viewer_sphere, 1)` + `init_path(cell_1, desired_cam_pos, …)`, pc:92860-92866). This is + the camera **spring-arm / collision** sweep — the camera is swept from the player toward the desired + chase position and stopped on geometry (the `SmartBox::update_viewer` spring arm acdream already ported). +3. On `find_valid_position` success: `SmartBox::set_viewer(this, &eax_8->sphere_path.curr_pos, 0); + this->viewer_cell = eax_8->sphere_path.curr_cell;` (pc:92870-92871). **The render's camera cell is the + `curr_cell` tracked through that transition — the exact same `validate_transition` mechanism physics uses + for the player.** +4. Fallbacks: if the camera transition fails, `AdjustPosition(&var_120, &viewer_sphere, &var_170, …)` + (pc:92878) resolves the cell statically and uses `var_170` (pc:92881); last resort `viewer_cell = null` + (pc:92887). + +So render does **not** maintain an independent cell graph. It traverses the **same** `CEnvCell`/`CCellPortal` +graph that physics uses, and it derives the camera cell from a transition's `sphere_path.curr_cell` — exactly +like the player. The object→cell associations that clip entities (C11) come from the physics +`enter_cell`/`leave_cell` shadow lists. One graph, one membership concept, two consumers (player movement and +camera). + +**Contrast with acdream:** acdream's render runs `CellVisibility.FindCameraCell(cameraPos)` +(`CellVisibility.cs:389`, "Ported from ACME EnvCellManager.cs FindCameraCell()") — an **independent** static +camera-cell resolver — and a separate `VisibilityResult`. The W2 work added `ComputeVisibilityFromRoot` that +*can* take the physics CurrCell as root (`CellVisibility.cs:534`), which is the right direction, but the +default path still resolves the camera cell on its own. This is precisely the "render maintains its own +cell/visibility system separate from physics" divergence the brief calls out. + +--- + +# D. Synthesis for acdream + +## D14. The retail-faithful target architecture + +**One cell-membership value, carried through the sweep, shared by physics and render.** + +1. **Physics membership = the swept `curr_cell`.** Stop re-deriving the cell from the static origin. The cell + the player is in is whatever the per-tick transition's `sphere_path.curr_cell` (acdream: + `SpherePath.CurCellId`) ended at — committed via a `change_cell`-style "only on difference" setter. + `validate_transition` already advances it on OK+moved and reverts it on block/standstill; that is the only + place membership should change. + +2. **`do_not_load_cells` prune for set-position paths.** Port the prune into `find_cell_list`, gated on a + `SetPositionStruct.flags & 0x20` analogue, so authoritative/teleport sets cannot drift the cell. (For free + movement the prune is *not* needed — accept-on-move + the directional picker suffice. Do **not** keep the + ad-hoc `DoorwayHoldMargin` hysteresis; it is a symptom-masking workaround of the static re-derive and is + forbidden by the project's no-workarounds rule once the real mechanism lands.) + +3. **Render obeys the physics cell + one portal-visibility traversal.** The render's root cell should be the + physics `CurrCell` (the camera-cell case is a *second* transition that tracks its own `curr_cell`, but + both come from the shared graph and the shared `validate_transition`). `ComputeVisibilityFromRoot` already + exists; make it the default and feed it the physics answer. Draw the outside through exit portals + (`HasExitPortalVisible` → clip the landscape to the portal region), instead of leaving a clear-color hole. + +## D15. Specifically: should membership advance inside the sweep, and should render obey it? + +**Yes to both, and the decomp is unambiguous:** + +- **Advance membership inside the sweep (drop the static re-derive).** Retail's `SetPositionInternal(CTransition)` + reads `arg2->sphere_path.curr_cell` and commits via `change_cell`-on-difference (pc:283403-283456). acdream + must do the same: `ResolveWithTransition` should return `sp.CurCellId` (the swept, accept-on-move cell), + **not** `ResolveCellId(sp.GlobalSphere[0].Origin, …)`. The static re-derive (`PhysicsEngine.cs:909/928`) is + the flicker source — it independently re-evaluates the boundary against a ±8 cm jittering origin every tick. + Because `ValidateTransition` already sets `sp.CurCellId = sp.CheckCellId` (`TransitionTypes.cs:3408`) and the + indoor cell-array picker already retargets `sp.CheckCellId` to the containing cell mid-sweep + (`TransitionTypes.cs:2074-2075`), the swept answer is already computed and stable — it's simply discarded. + **This is the single highest-leverage change and it is small.** + + *Critical caution:* this touches the collision sweep, where acdream has a long bug history (the #98 saga, + ~10 failed fixes). The change itself does not modify collision response — it changes only *which already- + computed cell id is returned*. Keep the collision math byte-for-byte; change only the return value and the + consumer in `PlayerMovementController`/`GpuWorldState` that reads `ResolveResult.CellId`. + +- **Render obeys the physics `curr_cell` + single portal traversal.** Retail derives `viewer_cell` from a + transition's `curr_cell` (pc:92871) and builds the visible set with one `ConstructView` BFS (pc:433750). + acdream should root `CellVisibility` at the physics `CurrCell` answer (W2's `ComputeVisibilityFromRoot`) + rather than a separate `FindCameraCell`, and render the outside through exit portals. Justification: a + separate render cell system is exactly what produces the threshold strobe (render cell and physics cell + disagree by a frame) and the doorway clear-color hole (render never wires the exit portal to the landscape). + +## D16. Must-port functions, integration order, risks, conformance tests + +### Must-port / must-align functions (with retail addresses) + +**Physics — membership (the core):** +| Retail fn | Addr | pc:LINE | acdream status | +|---|---|---|---| +| `CTransition::validate_transition` | `0x50AA70` | 272547 | Present (`TransitionTypes.cs:3398`), advances `CurCellId` ✔ | +| `CPhysicsObj::SetPositionInternal(CTransition)` | `0x515330` | 283399 | **Missing the read** — `ResolveWithTransition` discards `sp.CurCellId` | +| `CPhysicsObj::change_cell` | `0x513390` | 281192 | Conceptually present (cell-id assignment); ensure "only on change" | +| `CObjCell::find_cell_list` (master) | `0x52B4E0` | 308742 | Partial (`CellTransit.FindCellList`); **needs the `do_not_load_cells` prune** + directional picker semantics | +| `CObjCell::find_cell_list` (sweep fwd) | `0x52B960` | 309085 | via `CellTransit.FindCellSet` | +| `CTransition::check_other_cells` | `0x50AE50` | 272717 | Present (`TransitionTypes.cs` CheckOtherCells), retargets `CheckCellId` ✔ | +| `CEnvCell::find_transit_cells` | `0x52C820` | 309968 | Present (portal expansion + outside add) | +| `CEnvCell::point_in_cell` | `0x52C300` | 309677 | Present (CellBSP point test) | +| `CEnvCell::check_building_transit` | `0x52C5D0` | 309827 | Present (`CellTransit.CheckBuildingTransit`) | +| `CLandCell::add_all_outside_cells` | `0x533630` | 317499 | Present (`AddAllOutsideCells`) — verify `added_outside` once-guard | +| `CPhysicsObj::AdjustPosition` | `0x511D80` | 280009 | Use for **initial** cell resolution only (teleport/login), not per-tick | +| `CEnvCell::find_visible_child_cell` | `0x52DC50` | 311397 | Present (`CellVisibility`/ACE port) — for initial resolve + exit detection | + +**Render — seamless seal:** +| Retail fn | Addr | pc:LINE | acdream status | +|---|---|---|---| +| `SmartBox::RenderNormalMode` | `0x453AA0` | 92635 | The indoor/outdoor decision tree to mirror | +| `SmartBox::update_viewer` | `0x453CE0` | 92761 | Spring-arm ported; **also set render root = transition `curr_cell`** | +| `PView::DrawInside` | `0x5A5860` | 433793 | acdream `GetVisibleCellsFromRoot` is the BFS analogue | +| `PView::ConstructView(CEnvCell)` | `0x5A57B0` | 433750 | Portal BFS ✔ (mirror exists) | +| `PView::ConstructView(CBldPortal)` | `0x5A59A0` | 433827 | Exterior→interior recursion (outside-looking-in) — **not yet** | +| `PView::ClipPortals` | `0x5A5520` | 433572 | **Exit-portal→`outside_view`** copy is the missing seam | +| `PView::DrawCells` | `0x5A4840` | 432709 | **`outside_view>0 ⇒ LScape::draw` clipped** + per-cell object clip | +| `PView::GetClip` | `0x5A4320` | 432344 | Portal screen-clip builder | +| `LScape::update_viewpoint` / `Position::get_outside_cell_id` | `0x5062D0` / `0x4527B0` | — / 91552 | Pre-position terrain for doorway draw | + +### Integration order (lowest-risk first) + +1. **Membership return fix (physics).** Change `ResolveWithTransition` to return `sp.CurCellId` (the swept + cell) instead of `ResolveCellId(origin,…)`. Delete the `DoorwayHoldMargin`/sphere-overlap hysteresis in + `ResolveCellId` *only after* this lands clean (it becomes dead). Add the `change_cell`-on-difference + setter semantics so the W2 `CellGraph.CurrCell` writer fires only on actual change. **Verify the flicker is + gone with `ACDREAM_PROBE_CELL` (one `[cell-transit]` per real cell change — should be ~1 at the doorway, + not 20+/sec).** This is the keystone; do it alone, verify, commit. +2. **`do_not_load_cells` prune (physics, set-position only).** Add the flag to the cell-array build, set it on + authoritative/teleport set-position, port the prune loop from `find_cell_list` (pc:308829-308867 / + ACE `ObjCell.cs:387-413`). Confirms constrained sets don't drift the cell. Conformance test below. +3. **Render root = physics cell.** Make `CellVisibility` default to `ComputeVisibilityFromRoot(physics CurrCell)` + (camera-cell variant for 3rd person tracks its own viewer transition, but rooted in the same graph). Remove + the independent `FindCameraCell` default once verified. Kills the threshold strobe. +4. **Draw the outside through exit portals (render).** When `HasExitPortalVisible`, clip the landscape draw to + the exit-portal screen region (`GetClip` analogue) and draw terrain+sky there, pre-positioned via + `get_outside_cell_id`/`update_viewpoint`. Removes the blue clear-color hole; caps the dungeon (no exit + portal ⇒ no landscape). Mirror `PView::DrawCells`'s `outside_view>0 ⇒ LScape::draw` gate. +5. **(Optional/last) exterior→interior recursion** (`ConstructView(CBldPortal)`) for "outside looking into a + building," if not already covered by the landscape→building portal path. + +### Main risks + +- **Touching the collision sweep.** The membership-return fix is *adjacent* to the sweep but changes no + collision math — keep it that way. Do not "improve" `find_cell_list` or `check_other_cells` while in there. + The #98 saga proves speculative sweep edits regress. Land step 1 in isolation, verify, commit before step 2. +- **`change_cell`-on-difference must be exact.** If acdream commits the cell unconditionally (even when equal) + it could re-fire `enter_cell`/`leave_cell` side-effects (shadow-list churn) every tick — verify the setter + early-returns on `this->cell == newCell` (retail pc:283414). +- **The directional picker must prefer interior cells.** If acdream's `FindCellList` returns the *first* + containing cell regardless of type (instead of "first **interior** containing cell wins, break"), the + threshold can still pick outdoors. Match pc:308814-308819 / ACE `ObjCell.cs:378-382` exactly. +- **Render root timing.** The render must read the *current frame's* committed physics cell (after the physics + tick), not a stale one, or the strobe just moves. Order: physics tick → commit `CurrCell` → camera viewer + transition → render BFS. +- **Dungeon vs cottage must both work from one path.** The same code must seal a dungeon (no exit portal ⇒ no + terrain) and a cottage (exit portal ⇒ terrain through doorway). Test both. + +### Conformance tests that would prove faithfulness + +1. **Standing-still cell stability (the flicker test).** Place the player at the cottage threshold + (the `0xA9B40170 ↔ 0xA9B40031` spot), run N≥120 physics ticks with zero input. Assert `CurrCell` changes + **0** times after the initial settle (retail: `validate_transition`'s no-move branch never promotes). This + is the direct regression guard for the bug. +2. **Doorway crossing is monotone.** Walk slowly outdoor→vestibule→room and back. Assert the `[cell-transit]` + sequence is a clean monotone chain (`0031 → 0170 → 0157 …` then reverse) with exactly one transition per + real boundary crossing — no oscillation, no skipped cells. +3. **`validate_transition` accept/revert unit test.** Drive `ValidateTransition` with (a) OK+moved → assert + `CurCellId == CheckCellId`; (b) OK+not-moved → assert `CurCellId` unchanged; (c) Collided/Slid → assert + `CurCellId` unchanged and `CheckPos == CurPos`. Mirrors pc:272593/272600/272612 and ACE `Transition.cs`. +4. **`find_cell_list` directional picker + prune.** Synthetic cell set where the foot sphere overlaps both an + interior cell and the outdoor landcell: assert the picked containing cell is the **interior** one. With + `do_not_load_cells` set and a stranger cell present (not current, not in stab list): assert it's removed + from the array; current cell and stab-list cells retained. (Port from ACE `ObjCell.cs` golden behavior.) +5. **Building entry/exit.** From outdoors, walk into a cottage door: assert `CurrCell` advances to the + interior EnvCell when the foot sphere crosses the `CBldPortal` (via `CheckBuildingTransit`). From inside, + walk out: assert `CurrCell` returns to the outdoor landcell via the `seen_outside`/`adjust_to_outside` exit. +6. **Render seal (visual + assertion).** Standing in the cottage facing the open door: assert the visible-set + build reports `HasExitPortalVisible == true` and that the landscape is drawn (no clear-color region in the + doorway). Standing in a sealed dungeon cell: assert `HasExitPortalVisible == false` and **no** terrain/sky + draw call. (The first is the "see rain through the door" target; the second is the "dungeon has no sky" + target.) +7. **Render cell == physics cell.** After a physics tick, assert `CellVisibility` root cell id == player's + committed `CurrCell` id (no independent re-resolve divergence). + +--- + +## Appendix: struct field anchors (verbatim from `acclient.h`, VERIFIED) + +- `SPHEREPATH` — `acclient.h:32625-32671` (curr_cell:32641, check_cell:32647, hits_interior_cell:32655, + cell_array_valid:32666, num_sphere:32627, global_curr_center:32635). +- `CELLARRAY` — `acclient.h:31574-31580` (added_outside, do_not_load_cells, num_cells, cells). +- `CELLINFO` — `acclient.h:31925-31929` (cell_id, cell). +- `CObjCell` — `acclient.h:30915-30932` (pos, num_objects/object_list, num_shadow_objects/shadow_object_list, + restriction_obj, num_stabs/stab_list:30927-30928, **seen_outside:30929**, myLandBlock_). +- `CSortCell : CObjCell` — `acclient.h:31880-31883` (building). +- `CLandCell : CSortCell` — `acclient.h:31886-31890` (polygons, in_view). +- `CEnvCell : CObjCell` — `acclient.h:32072-32091` (structure, num_portals/portals, num_static_objects/ + static_objects, light_array, **num_view/portal_view:32089-32090**). +- `CCellStruct` — `acclient.h:32275-32290` (portals(CPolygon**), polygons, **drawing_bsp/physics_bsp/cell_bsp**). +- `CCellPortal` — `acclient.h:32300-32308` (other_cell_id, other_cell_ptr, portal, portal_side, + other_portal_id, exact_match). +- `CBldPortal` — `acclient.h:32094-32103` (portal_side, other_cell_id, other_portal_id, exact_match, + num_stabs/stab_list, sidedness). +- `CBuildingObj : CPhysicsObj` — `acclient.h:31908-31916` (num_portals/portals, num_leaves/leaf_cells, shadow_list). +- `CLandBlockInfo` — `acclient.h:31893-31905` (num_objects/object_ids/object_frames, num_buildings/buildings, + restriction_table, cell_ownership, num_cells/cell_ids/cells). + +## Appendix: address index (all VERIFIED in `symbols.json` + pseudo-C) + +Physics: `change_cell` 0x513390 · `SetPositionInternal(CTransition)` 0x515330 · +`SetPositionInternal(Position,SetPositionStruct,CTransition)` 0x515BD0 · `validate_transition` 0x50AA70 · +`validate_placement_transition` 0x50ADC0 · `check_collisions` 0x50AA00 · `check_other_cells` 0x50AE50 · +`transitional_insert` 0x50B6F0 · `find_transitional_position` 0x50BDF0 · `find_valid_position` 0x50C310 · +`init_path`(SPHEREPATH) 0x50CE20 · `find_cell_list`(master) 0x52B4E0 · `find_cell_list`(sweep fwd) 0x52B960 · +`CObjCell::GetVisible` 0x52AD40 · `CEnvCell::GetVisible` 0x52DC10 · `CLandCell::GetVisible` 0x52DB0(→get_landcell) · +`CEnvCell::find_transit_cells` 0x52C820 · `CLandCell::find_transit_cells` 0x533800 · +`CSortCell::find_transit_cells` 0x534060 · `CEnvCell::point_in_cell` 0x52C300 · `CLandCell::point_in_cell` 0x532D40 · +`CEnvCell::check_building_transit` 0x52C5D0 · `CLandCell::add_all_outside_cells` 0x533630 · +`CLandCell::find_collisions` 0x532D60 · `CBuildingObj::find_building_transit_cells` 0x6B5230 · +`CBuildingObj::find_building_collisions` 0x6B5300 · `CCellPortal::GetOtherCell` 0x53BA30 · +`CBldPortal::GetOtherCell` 0x53BC30 · `AdjustPosition` 0x511D80 · `CheckPositionInternal` 0x511E90 · +`find_visible_child_cell` 0x52DC50. + +Render: `SmartBox::RenderNormalMode` 0x453AA0 · `SmartBox::update_viewer` 0x453CE0 · `RenderDeviceD3D::DrawInside` +0x59F0D0 · `PView::DrawInside` 0x5A5860 · `PView::ConstructView(CEnvCell)` 0x5A57B0 · +`PView::ConstructView(CBldPortal)` 0x5A59A0 · `PView::InitCell` 0x5A4B70 · `PView::ClipPortals` 0x5A5520 · +`PView::AddViewToPortals` 0x5A52D0 · `PView::DrawCells` 0x5A4840 · `PView::GetClip` 0x5A4320 · +`PView::AddToCell` 0x5A4D90 · `PView::OtherPortalClip` 0x5A5400 · `LScape::draw` 0x506330 · +`LScape::update_viewpoint` 0x5062D0 · `Position::get_outside_cell_id` 0x4527B0. diff --git a/docs/research/2026-06-02-retail-cell-render-study-sonnet46.md b/docs/research/2026-06-02-retail-cell-render-study-sonnet46.md new file mode 100644 index 0000000..a4f2dfa --- /dev/null +++ b/docs/research/2026-06-02-retail-cell-render-study-sonnet46.md @@ -0,0 +1,811 @@ +# Retail AC: Cell Transitions, Underground/Dungeons, and Seamless Indoor/Outdoor Rendering +## Independent Research Study — claude-sonnet-4-6 — 2026-06-02 + +> **Scope:** This is an independent study of the retail AC client (Sept 2013 EoR build, Sept PDB) +> as primary oracle, cross-checked against ACE and the acdream codebase. Every non-trivial +> claim is cited with `function @ 0xADDR (pseudo_c:LINE)` or `repo/file:LINE`. Inferences are +> flagged explicitly. + +--- + +## A. Cell Membership & Transitions (Physics) + +### A1. How retail stores and updates `curr_cell` + +**The authoritative field is `SPHEREPATH::curr_cell` (acclient.h:32641).** + +`SPHEREPATH` (acclient.h:32625–32671) contains two parallel position/cell pairs: + +| Field | Purpose | +|-------|---------| +| `curr_cell` / `curr_pos` | The **accepted** position after the last completed move | +| `check_cell` / `check_pos` | The **candidate** position being tested in the current sweep step | +| `begin_cell` / `begin_pos` | The position at the **start** of this full transition | + +`SPHEREPATH::curr_cell` is **not** a cache of some ID-to-pointer lookup — it is the live pointer +to the `CObjCell` the physics sphere currently inhabits. The `CPhysicsObj` also maintains +`this->cell` (set by `enter_cell`/`leave_cell`), which mirrors `curr_cell` after +`SetPositionInternal` completes the write-back. + +**Where `curr_cell` is updated during a transition:** + +1. **`CTransition::validate_transition` @ `0x0050aa70` (pseudo_c:272547)** + — the single canonical place where a step is *accepted*. + ``` + // On OK path (move accepted, check_pos != curr_pos): + this->sphere_path.curr_pos = check_pos // line 272609-272611 + this->sphere_path.curr_cell = check_cell // line 272612 + // then resets check_pos/check_cell to the new curr: + this->sphere_path.check_pos = curr_pos // line 272615-272616 + this->sphere_path.check_cell = curr_cell // line 272617 + // cell_array_valid cleared so next build_cell_array refreshes + this->sphere_path.cell_array_valid = 0 // line 272618 + + // On non-OK path (collision, slide): + // check_pos is reset BACK to curr_pos (line 272593) + // curr_cell is NOT changed — sphere stays in curr_cell + ``` + **Key invariant**: `curr_cell` only advances when `validate_transition` accepts the move + (returns `OK_TS` with `check_pos != curr_pos`). A bounce/slide resets `check_pos` to + `curr_pos` without touching `curr_cell`. + +2. **`CTransition::check_other_cells` @ `0x0050ae50` (pseudo_c:272717)** + — updates `sphere_path.check_cell` after `find_cell_list` picks a containing cell: + ``` + CObjCell::find_cell_list(&cell_array, &var_4c, &sphere_path); // line 272725 + // var_4c = the containing cell picked by point_in_cell scan + sphere_path.check_cell = var_4c; // line 272761 + if (var_4c != 0): + adjust_check_pos(var_4c->id) // line 272765 + ``` + This is what _replaces_ `check_cell` with the correct new cell across a portal + boundary during a sweep step. + +3. **`CPhysicsObj::SetPositionInternal` @ `0x00515330` (pseudo_c:283399)** + — the write-back from transition result to `CPhysicsObj::cell`: + ``` + CObjCell* curr_cell = arg2->sphere_path.curr_cell; // line 283403 + if (curr_cell == 0): prepare_to_leave_visibility(); store_position(); + else: + if (this->cell == curr_cell): + // same cell — just update the cell id on position/parts + this->m_position.objcell_id = curr_pos.objcell_id; + else: + change_cell(this, curr_cell); // line 283456 + ``` + `CPhysicsObj::cell` is **only updated here**, from `sphere_path.curr_cell` delivered by + the completed transition. There is NO intermediate re-derive from world position. + +4. **`CPhysicsObj::change_cell` @ `0x00513390` (pseudo_c:281192)** + — the actual leave/enter: + ``` + if (this->cell != 0): leave_cell(this, 1); + if (arg2 != 0): enter_cell(this, arg2); + else: this->cell = nullptr; + ``` + `enter_cell` @ `0x00510ed0` (pseudo_c:278928) calls `CObjCell::add_object(arg2, this)` + to register the physics object in the new cell's object list. + +**Summary chain:** +``` +transitional_insert (sweep loop) + → insert_into_cell (per-cell BSP test) + → check_other_cells (find_cell_list → pick containing cell → update check_cell) + → validate_transition (accept: curr_cell = check_cell; reject: reset check to curr) + → [loop until done] +→ SetPositionInternal (write curr_cell → change_cell → CPhysicsObj::cell) +``` + +### A2. `find_cell_list`: candidate building and containing-cell selection + +**`CObjCell::find_cell_list` @ `0x0052b4e0` (pseudo_c:308742)** — this is the workhorse. +Signature (from the 6-argument overload): +```cpp +void CObjCell::find_cell_list( + Position const* pos, + uint32_t numSphere, + CSphere const* spheres, + CELLARRAY* cellArray, + CObjCell** containingCell, // OUT: the single containing cell + SPHEREPATH* path) +``` + +Step-by-step: + +1. **Seed the starting cell** (lines 308751–308769): + ``` + if pos.objcell_id >= 0x100: visible = CEnvCell::GetVisible(id) // indoor + else: visible = CLandCell::GetVisible(id) // outdoor + if id >= 0x100: + path->hits_interior_cell = 1 + CELLARRAY::add_cell(cellArray, id, visible) + else: + CLandCell::add_all_outside_cells(pos, numSphere, spheres, cellArray) + ``` + +2. **Expand via `find_transit_cells`** (lines 308771–308786): + Each cell already in the array is asked to add any neighboring cells that the + sphere overlaps through portals: + ``` + for each cell in cellArray: + cell->vtable->find_transit_cells(pos, numSphere, spheres, cellArray, path) + ``` + For `CEnvCell::find_transit_cells` @ `0x0052c820` (pseudo_c:309968): for each portal, + checks if any sphere overlaps the portal plane (with radius epsilon). If so, either adds + `portal.other_cell` (if resolved) or adds the portal's landcell side. + For `CLandCell::find_transit_cells` @ `0x00533800` (pseudo_c:317603): calls + `add_all_outside_cells` then `CSortCell::find_transit_cells`. + +3. **Pick the containing cell** (`*containingCell`, lines 308788–308827): + ``` + *containingCell = nullptr + for each cell in cellArray: + blockOffset = LandDefs::get_block_offset(pos.objcell_id, cell.id) + localPoint = sphere[0].center - blockOffset + if cell->vtable->point_in_cell(localPoint): + *containingCell = cell + if cell.id >= 0x100: + path->hits_interior_cell = 1 + break // interior cell wins immediately, no further scan + ``` + **Interior cell wins over landcell** — once a `CEnvCell` is found to contain the + point, the loop breaks. Outdoor landcells are not preferred over interior cells. + +4. **`do_not_load_cells` prune** (lines 308829–308867): + ``` + if cellArray.do_not_load_cells AND pos.objcell_id >= 0x100: + remove any cell from cellArray whose id is not: + - the id of the 'visible' (GetVisible) cell, or + - one of visible's stab_list members (its PVS set) + ``` + This prune runs **after** the containing-cell scan. It removes cells that are + reachable via portal overlaps but not actually visible from the current interior + starting cell. It only fires when `do_not_load_cells = 1` AND the position is + already indoors (id >= 0x100). The prune uses `visible`'s `stab_list` array — + the precomputed PVS of cells visible from that EnvCell — as the whitelist. + +**What stability the prune buys:** When the sphere straddles a portal into a +neighboring `CEnvCell`, the transition normally adds that neighbor to the +`cellArray` and may pick it as containing cell. With `do_not_load_cells`, neighbors +NOT in the current cell's PVS are stripped — the sphere can only "move into" a cell +that is visible from where it currently is. This prevents teleporting through walls +into cells whose portals don't connect to the current room. + +**When `do_not_load_cells` is set:** +- `CPhysicsObj::SetPositionInternal` @ `0x00515bd0` (pseudo_c:283930) sets it: + `cell_array.do_not_load_cells = 1` (line 283930) before calling `find_cell_list`. + This is the placement/teleport path, not the per-frame movement path. +- Also set at `0x00519895` (pseudo_c:287856) in the detection manager. +- The normal movement transition (`find_transitional_position`) path does NOT set + `do_not_load_cells` — the sweep is allowed to discover any adjacent cell. + +**CELLARRAY struct (acclient.h:31574–31580):** +```cpp +struct CELLARRAY { + int added_outside; + int do_not_load_cells; + uint num_cells; + DArray cells; // each entry: {uint cell_id; CObjCell* cell;} +}; +``` + +### A3. How retail avoids cell flicker at boundaries + +Retail's anti-flicker guarantee is **directional** — `curr_cell` only advances when a step +is **accepted** by `validate_transition`. A blocked step resets `check_pos`/`check_cell` +back to `curr_pos`/`curr_cell` (pseudo_c:272593). The player standing still fires collision +tests that may push-back but never accept, so `curr_cell` never changes. + +The key mechanisms: + +1. **Sweep-path tracking, not static lookup.** The sweep starts at `curr_cell` and + advances `check_cell` through portals only when the sphere physically crosses + through a portal (detected by `find_transit_cells`). A position jitter of ±8cm + across a door frame does NOT cause `curr_cell` to flip — it would only flip if + `validate_transition` accepted a step that moved `check_pos` past the portal. + +2. **`point_in_cell` semantics.** The containing-cell selection in `find_cell_list` + uses `CObjCell::point_in_cell` (vtable+0x84) which for `CEnvCell::point_in_cell` + @ `0x0052c300` (pseudo_c:309677) does a BSP containment test: + ``` + eax_1 = this->vtable->point_in_cell(arg2) // delegates to CCellStruct::point_in_cell + ``` + `CCellStruct::point_in_cell` @ `0x005338f0` (pseudo_c:317657) tests the BSP. The + BSP boundary is not a simple plane — it is the full convex cell volume defined by + the portal geometry. So "across the threshold" in world space may still be + "inside the cell" in BSP space for some margin. + +3. **Interior cell wins the point_in_cell scan.** In the containing-cell scan + (pseudo_c:308814-308820), once an interior cell (`id >= 0x100`) passes + `point_in_cell`, the loop breaks immediately. This ensures that even if the + outdoor landcell ALSO passes `point_in_cell` (because the player is standing + ON a building's footprint), the indoor cell takes priority. + +4. **`check_cell` is set from the collision result, not re-derived from world position.** + After `check_other_cells` updates `check_cell` to the `find_cell_list` result, the + next iteration of the sweep uses THAT `check_cell` as the source for + `insert_into_cell`. There is no re-derive from the player's world XYZ. + +5. **Standing still:** When `targetPos == currentPos`, the transition has zero steps. + `find_transitional_position` returns immediately (ACE Transition.cs:528–529). + `curr_cell` is never touched. _(Inference: based on the ACE port — retail likely + has the same short-circuit.)_ + +**acdream's divergence:** `PhysicsEngine.cs:909/928` calls `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` which re-derives the cell from the final world XYZ via `CellTransit.FindCellList`. This is a static BFS from position. If the BSP push-back moves the sphere center 8cm outside the indoor cell's BSP volume, `FindCellList` can return the outdoor landcell — which is exactly the ping-pong symptom. Retail never does this; it reads `sphere_path.curr_cell` directly. + +### A4. Indoor→outdoor and outdoor→indoor transitions; CCellPortal vs CBldPortal + +**CCellPortal (acclient.h:32300)** — connects two `CEnvCell`s within the same building +or dungeon: +```cpp +struct CCellPortal { + uint other_cell_id; // id of the neighbor EnvCell + CEnvCell* other_cell_ptr; // live pointer (null when not loaded) + CPolygon* portal; // the polygon defining the opening + int portal_side; // which side of the polygon is "inside" this cell + int other_portal_id; // index of corresponding portal in other_cell + int exact_match; // match only when crossing from exact side +}; +``` + +**CBldPortal (acclient.h:32094)** — connects a building's `CBuildingObj` to the outdoor +landscape or to an `EnvCell` within the building: +```cpp +struct CBldPortal { + int portal_side; // 0 or 1 + uint other_cell_id; // 0 for outdoor exits + int other_portal_id; // -1 for outdoor exits + int exact_match; + uint num_stabs; + uint* stab_list; // PVS of interior cells visible through this portal + float sidedness; +}; +``` + +**Outdoor → indoor entry:** +`CEnvCell::check_building_transit` @ `0x0052c5d0` (pseudo_c:309827) is called from +`find_transit_cells`. For each portal in the building (CBldPortal), it tests whether +any physics sphere overlaps the cell BSP (`CCellStruct::sphere_intersects_cell`). +If so, it calls `CELLARRAY::add_cell(arg5, this->m_DID.id, this)` — adding the +interior EnvCell to the collision candidate array. After the next `find_cell_list` +run, `point_in_cell` on the indoor cell will win (interior > outdoor), and +`check_cell` becomes the indoor cell. On the next `validate_transition` accept, +`curr_cell` advances to the indoor cell. + +**Indoor → outdoor exit:** +`CEnvCell::find_transit_cells` @ `0x0052c820` (pseudo_c:309968): when a portal's +`other_cell_id == 0xFFFFFFFF` (exit portal — sentinel value), the code tests whether +any sphere plane is within epsilon of the exit portal plane (pseudo_c:309983–310030). +If so, it calls `CLandCell::add_all_outside_cells` (line 310120) — adding the +surrounding landcells. The point_in_cell scan then picks the outdoor landcell. + +**Between interior cells:** Handled purely via `CCellPortal` in `find_transit_cells`. +If the sphere overlaps the `CCellPortal.portal` polygon (plane-distance test with radius +epsilon), `other_cell_ptr` (if non-null) is added to the array. + +### A5. The two arrays: `CELLARRAY` for collision vs `curr_cell` for membership + +These are NOT the same. `CTransition` owns both: +```cpp +// CTransition (acclient.h:52332–52335): +SPHEREPATH sphere_path; // contains curr_cell, check_cell +CELLARRAY cell_array; // the candidate array for collision testing +``` + +**`cell_array`** is rebuilt each step by `build_cell_array` → `find_cell_list`. It contains +ALL cells whose BSPs the sphere might overlap (typically 1–4 cells at a boundary). Every +cell in the array gets an `insert_into_cell` collision test. + +**`curr_cell`** in `sphere_path` is the single cell that CONTAINS the sphere center — +the membership answer. It's updated only via `validate_transition`'s accept path. + +The relationship: each step's `find_cell_list` scan computes both (1) the candidate +array for collision and (2) the containing cell (`*containingCell` arg). They share the +same `find_cell_list` call but serve different purposes. + +--- + +## B. Underground / Dungeons + +### B6. Dungeon representation — EnvCell graph vs surface buildings + +**Surface buildings** (cottages, inns): +- Built on top of a landblock with terrain. +- `CLandBlockInfo.buildings` array references `CBuildingObj` instances. +- Each building's `CBuildingObj` has `portals` (array of `CBldPortal*`) connecting it + to the indoor `CEnvCell` graph and to the outdoor landscape. +- The `CLandCell` for that terrain square is always present; the `CEnvCell` cells of the + building sit "on top" of it spatially but are physically separate cells. +- `CEnvCell::seen_outside` (acclient.h:30929, type `int`) is **non-zero** for cells + that have at least one exit portal reaching the landscape (inn doorway cells, cellar + stairs top-cell, etc.). + +**Dungeons:** +- Represented as a pure `CEnvCell` graph, loaded from the DAT as the `CLandBlockInfo` for + the dungeon block. There is no terrain `CLandCell` in the dungeon landblock. +- All `CEnvCell`s in a dungeon have `seen_outside = 0` (pseudo_c:311370 shows it's + zeroed on init; the unpack path at pseudo_c:311044 / 311057 can set it from DAT data, + but dungeon cells universally have no outdoor reachability). +- The dungeon is entered via a portal link from a surface landblock `CEnvCell` (the + dungeon entrance cell) to the dungeon's first cell, mediated by the same + `find_transit_cells` mechanism. + +**At runtime** there is no explicit "I am in a dungeon" boolean. The engine tests +`curr_cell->seen_outside` to decide whether terrain/sky applies. + +### B7. Player movement through a dungeon + +Cell tracking in a dungeon is identical to inside a surface building — the same +`CEnvCell` sweep via `CCellPortal`. The dungeon cell graph is self-contained. The +absence of `CLandCell` means `find_cell_list` never adds outdoor cells: +- `CObjCell::find_cell_list` seeds from `CEnvCell::GetVisible(id)` (id >= 0x100 branch), + adds only the starting EnvCell. +- `CEnvCell::find_transit_cells` tests each portal. Exit portals with + `other_cell_id == 0xFFFFFFFF` exist in surface buildings to reach the landscape; + dungeon cells don't have such portals — their portals all connect to other dungeon + `CEnvCell`s. + +Streaming/loading: `CEnvCell::grab_visible_cells` @ `0x0052e220` (pseudo_c:311880): +``` +add_visible_cell(this->id) +for each stab in stab_list: add_visible_cell(stab) // the PVS +if seen_outside != 0: LScape::grab_visible_cells() // only for outdoor-reachable cells +``` +Dungeon cells (seen_outside==0) never trigger `LScape::grab_visible_cells`. The landscape +is completely excluded from their rendering context. + +### B8. The "underground" flag + +**There is no explicit `is_dungeon` or `is_underground` boolean** on Position, landblock, +or cell. The engine uses `CObjCell::seen_outside` (acclient.h:30929) as the semantic gate: +- Non-zero: this cell can see the outside world (sky, terrain visible through portals/exits) +- Zero: fully enclosed (pure dungeon cell, or interior cell with no exterior windows) + +The decision tree (from `SmartBox::RenderNormalMode` @ `0x00453aa0`, pseudo_c:92635): +``` +edi_2 = (viewer_cell == null) ? 1 : 0 // no viewer cell = outdoor default +if edi_2 == 0: + ebx_1 = viewer_cell->seen_outside != 0 // 1=can see outside, 0=sealed +if edi_2 == 0: + if ebx_1: LScape::update_viewpoint + DrawInside(viewer_cell) // indoor+terrain + else: DrawInside(viewer_cell) only (no terrain) +else: + LScape::update_viewpoint + LScape::draw // pure outdoor +``` +This is the master terrain/sky gate. The `seen_outside` field on `viewer_cell` (the +render-side cell for the viewer position) determines whether terrain renders. + +`CellManager::ChangePosition` @ `0x004559b0` (pseudo_c:94601) also reads `seen_outside` +on the new `curr_cell` to decide whether to `LScape::release_all` vs `grab_visible_cells` +(pseudo_c:94649–94661). For a cell with `seen_outside != 0`, it calls +`LScape::update_loadpoint` to keep terrain around the outdoor cell ID loaded. + +--- + +## C. Rendering Inside and Outside + +### C9. The PView visibility traversal — one pass, one BFS + +Retail's render visibility is built by **`PView::ConstructView`** @ `0x005a57b0` +(pseudo_c:433750). This is a **breadth-first portal traversal**, not a recursive frustum +split. The same PView instance is reused for all cells seen from the current +`viewer_cell`. + +``` +PView::ConstructView(this, rootCell, incomingPortalIdx): + reset outside_view, master_timestamp, cell_todo_num, cell_draw_num + InitCell(this, rootCell, 0xFFFF) // set up per-portal visibility masks + InsCellTodoList(this, rootCell, 0f) // push root into the work queue + + while cell_todo_num > 0: + cell = pop from cell_todo_list + add cell to cell_draw_list + cell.portal_view[last].cell_view_done = 1 + if ClipPortals(this, cell, 0): // compute per-portal clip regions + AddViewToPortals(this, cell) // propagate visibility through each portal +``` + +**`InitCell`** @ `0x005a4b70` (pseudo_c:432896) initializes the per-portal visibility +state for the root cell: it checks each portal's plane against the current viewer +position (`Render::FrameCurrent`) to determine which portals face the viewer. Portals +that face away are marked as non-visible. The result is stored in `portal_view_type` +structs on the `CEnvCell`. + +**`ClipPortals`** clips each visible portal to the accumulated clip region (the frustum +intersection of all portals traversed so far). If a portal's clip region is non-empty, +`AddViewToPortals` is called, which calls `ConstructView` **recursively** for the +neighboring cell through that portal (pseudo_c:433879): +``` +if arg5 != 1: + PView::ConstructView(this, eax_4, arg2->other_portal_id) +``` + +The result is `cell_draw_list` — an ordered list of `CEnvCell*` to draw, built in +visibility order from the viewer. + +**Output:** `PView::cell_draw_list` (acclient.h:45939) — a `DArray`, the +ordered draw list. `PView::outside_view` accumulates outdoor portal entries for +landscape/sky draws. + +### C10. Outside seen through a doorway — exit portals and `seen_outside` + +When the traversal reaches a cell with an **exit portal** (CBldPortal with +`other_cell_id == 0`), `PView::ConstructView(this, bldPortal, polygon, ...)` is called +(the `CBldPortal` overload at `0x005a59a0`, pseudo_c:433827): + +``` +PView::ConstructView(this, bldPortal, polygon, arg4, arg5): + test viewer position against portal plane (sidedness check): + if portal_side correct: + GetClip(this, sidedness, polygon, &clip_view, ...) + if clip_view non-empty: + eax_4 = CEnvCell::GetVisible(bldPortal->other_cell_id) // indoor neighbor + if arg5 != 2: + D3DPolyRender::DrawPortalPolyInternal(polygon, ...) // draw the portal hole + PView::ConstructView(this, eax_4, bldPortal->other_portal_id) // recurse indoor +``` + +For **outdoor exit** portals specifically, the exit goes to the `outdoor_pview` via +`PView::DrawPortal` @ `0x005a5ab0` (pseudo_c:433895). The `outdoor_pview` is the +landscape PView; `DrawPortal` calls `ConstructView(outdoor_pview, bldPortal, portal, ...)`. +Inside `PView::DrawCells` @ `0x005a4840` (pseudo_c:432709), when `outside_view.view_count > 0`: +``` +Render::useSunlightSet(1) +Render::PortalList = this +LScape::draw(this->lscape) // terrain + sky through this portal's clip region +D3DPolyRender::FlushAlphaList(0f) +// ... then draw the indoor env cells +``` + +The landscape draw uses `Render::PortalList` (set to `this` PView) to clip the terrain +to the portal opening's region — only the terrain visible through that portal hole is +drawn. This is how the outside world appears through a doorway without a blue hole. + +**No blue-hole guarantee:** The portal hole is always either (a) filled by the +`D3DPolyRender::DrawPortalPolyInternal` call (which masks the stencil/z-buffer for the +opening) or (b) reveals a valid outdoor PView result. The `DrawPortalPolyInternal` call +draws the portal polygon as a "window" into the outdoor view. The outdoor view is computed +by `outdoor_pview` using its `ConstructView` with the portal's clip shape as the +accumulated frustum. + +### C11. How retail seals interiors — ceiling caps, entity clip, portal masking + +Retail's interior seal comes from the PView system itself: + +1. **Portal-hole masking:** Each portal polygon is drawn as a "window" into the neighboring + view. The rendering device clips draws to the visible portal region. Cells not in + `cell_draw_list` are never drawn. + +2. **`cell_draw_list` is the only draw gate.** `PView::DrawCells` iterates only + `cell_draw_list.data[0..cell_draw_num]`. An entity or particle in a cell not in + `cell_draw_list` is not drawn. There is no separate frustum cull — portal visibility + IS the culling. + +3. **Ceiling/floor capping:** The `CCellStruct` BSP for each `CEnvCell` includes all + surfaces (floor, ceiling, walls). When `DrawEnvCell` renders the cell geometry, all + surfaces including the ceiling are drawn. The only surfaces NOT drawn are portals + (they get `DrawPortalPolyInternal` treatment instead of normal polygon rendering) — + pseudo_c:432785–432791: + ``` + if portals[j].other_cell_ptr == 0xffffffff: + D3DPolyRender::DrawPortalPolyInternal(portal, 0) + ``` + So: ceiling is always part of the cell mesh and always drawn. There is no "open top" + unless the cell geometry has an actual hole. + +4. **Entity draw:** `DrawCells` also calls `DrawObjCellForDummies` (pseudo_c:432878) for + each cell in `cell_draw_list`, which draws entities registered in that cell's + `object_list`. Entities in non-visible cells are never drawn. + +### C12. Terrain and sky: the `seen_outside` gate + +The draw path (from `SmartBox::RenderNormalMode` @ `0x00453aa0`, pseudo_c:92635): + +``` +if viewer_cell == null OR viewer_cell->seen_outside != 0: + // Player is outdoor OR in a cell that can see outside + if viewer_cell != null (indoor-reachable-outside case): + LScape::update_viewpoint(viewer.objcell_id) + Render::update_viewpoint(&viewer) + RenderDevice::DrawInside(viewer_cell) // fires PView traversal + portal terrain draws +else (viewer_cell != null AND seen_outside == 0): + // Player in sealed cell (dungeon or sealed room) + LScape::update_viewpoint(viewer.objcell_id) + Render::update_viewpoint(&viewer) + RenderDevice::DrawInside(viewer_cell) // PView traversal only, no landscape +``` + +Wait — actually reading more carefully (pseudo_c:92665–92684): + +``` +if edi_2 == 0 (viewer_cell != null): + if ebx_1 (seen_outside): LScape::update_viewpoint + DrawInside + else: DrawInside only (no landscape update) +else (viewer_cell == null): + LScape::update_viewpoint + LScape::draw // pure outdoor: direct landscape draw +``` + +Terrain through portals is NOT drawn via `LScape::draw` when indoor — it's drawn +inside `PView::DrawCells` via `LScape::draw(this->lscape)` only when +`outside_view.view_count > 0` (there are outdoor portal entries). This means: +- Sealed dungeon cell (`seen_outside=0`, no exit portals): `PView::DrawCells` never + sets `outside_view.view_count > 0`, so `LScape::draw` is never called. +- Building cell with exit portals: exit portal traversal adds an entry to + `outside_view`; `LScape::draw` fires inside `DrawCells`. + +**Sky** follows the same path: `LScape::draw` includes sky. When there are no outdoor +portal views, sky is not drawn. + +### C13. Render `viewer_cell` vs physics `curr_cell` — the same graph, different holders + +**Retail uses the SAME physical `CObjCell*` for both physics and render, but held by +different owners.** + +Physics: `CPhysicsObj::cell` (the player's `CPhysicsObj`) = current cell pointer, updated +by `change_cell` after `SetPositionInternal`. + +Render: `SmartBox::viewer_cell` (acclient.h:35194, type `CObjCell*`) = the render entry +point for `DrawInside`. This is updated by `SmartBox::update_viewer` +@ `0x00453ce0` (pseudo_c:92761). + +**`SmartBox::update_viewer`** (pseudo_c:92761–92892) is called every frame. It: +1. Reads `this->player->cell` (the physics cell). +2. If `player->cell == null`: calls `reenter_visibility`, sets `viewer_cell = null`. +3. Otherwise, runs a **camera position transition**: `CTransition::find_valid_position` + (line 92868) sweeps the `viewer_sphere` from `player_pos` to `viewer_sought_position` + (the 3rd-person camera target position). +4. **Sets `viewer_cell = transition.sphere_path.curr_cell`** (line 92871) — directly from + the camera transition result, NOT from re-deriving by position. +5. If the camera transition fails: falls back to `CPhysicsObj::AdjustPosition` on the + viewer sphere, setting `viewer_cell` from that result. +6. If both fail: `viewer_cell = nullptr` (outdoor). + +**Key insight:** `SmartBox::viewer_cell` is a **separate tracked pointer** for the +camera/render, but it is resolved via its own CTransition sweep (the camera spring-arm +sweep). It does NOT re-derive from the camera's world XYZ via `find_cell_list` directly — +it uses the transition result's `curr_cell`. When the camera is fully outdoor (no indoor +cell found), `viewer_cell` is null, and the outdoor path fires. + +**The two cell pointers:** +- `CPhysicsObj::cell` — physics membership, updated per-frame by `SetPositionInternal` +- `SmartBox::viewer_cell` — render root, updated per-frame by `update_viewer` via its own + camera transition sweep + +They share the same `CObjCell` graph (the runtime-loaded cells) but are tracked +independently. Critically, `viewer_cell` does NOT necessarily equal `player->cell` — in +3rd-person mode the camera can be in a different cell than the player body. + +The render traversal uses `viewer_cell` (not `player->cell`) as the PView root. This is +the CAMERA's cell, not the physics body's cell. + +--- + +## D. Synthesis and Recommended acdream Architecture + +### D14. Retail-faithful target architecture + +Given what the decomp shows, the correct architecture is: + +**Physics side (cell membership):** +- `sphere_path.curr_cell` tracks the player's cell THROUGH the sweep. +- `curr_cell` advances only when `validate_transition` accepts a step. +- Blocked/standing-still steps never change `curr_cell`. +- After the sweep, `SetPositionInternal` writes `sphere_path.curr_cell` → `CPhysicsObj::cell`. +- No static re-derive from world position after the sweep. + +**Camera/render side (viewer cell):** +- `SmartBox::viewer_cell` is resolved via its OWN `CTransition::find_valid_position` sweep + on the camera eye sphere each frame. +- This camera sweep returns its own `sphere_path.curr_cell` which becomes `viewer_cell`. +- `viewer_cell` is what the PView traversal roots from — not `player->cell`. +- The PView traversal (ConstructView → BFS → cell_draw_list) computes the entire visible + set from `viewer_cell` in ONE pass. +- Terrain/sky/landscape draw is gated on `viewer_cell->seen_outside`. + +**The two subsystems share the same cell graph** but track their own positions +independently. They can be in different cells (player body vs camera). + +### D15. Should acdream port `validate_transition`'s `curr_cell` advance + drop static re-derive? + +**Yes, unambiguously.** The decomp is clear on all three questions: + +**Q: Should membership advance inside the sweep?** +Yes. `validate_transition` @ `0x0050aa70` is the sole gate for advancing `curr_cell`. +Every accepted step updates `curr_cell`; every bounce resets `check_pos` to `curr_pos` +without touching `curr_cell`. The current `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` +call in acdream's `ResolveWithTransition` (PhysicsEngine.cs:909/928) must be replaced by +reading `sp.CurCell` (= `sphere_path.curr_cell`) directly, exactly as retail's +`SetPositionInternal` does (pseudo_c:283403). + +**Q: Should the `do_not_load_cells` prune be added?** +Yes, but carefully. In retail it is set for PLACEMENT / TELEPORT paths (not for normal +movement). For the physics SWEEP (normal movement), `do_not_load_cells = 0`. For +`SetPositionInternal` placement path, `do_not_load_cells = 1`. acdream's +`CellTransit.FindCellList` currently has no such prune — this is a latent source of +spurious cross-wall cell candidates during teleports, but does NOT cause the per-frame +ping-pong (since it's only for teleports). Port it as a separate step, after fixing +the sweep tracking. + +**Q: Should render obey the physics `curr_cell` or a separate camera cell?** +Retail uses a **separate** camera cell (`SmartBox::viewer_cell`) computed by its own +camera transition sweep — NOT `player->cell` directly. The current acdream U.4c fix +(GameWindow.cs:7163) correctly uses the physics `CurrCell` as the PView root when the +camera is also indoors, but this diverges from retail for 3rd-person where camera ≠ +player body. The most retail-faithful fix is to run a camera cell transition sweep +(the retail `SmartBox::update_viewer` path) and use THAT result as the PView root, +falling back to the physics cell when the camera sweep fails. + +For the current flicker/flap bugs, the U.4c fix (root at player cell, not camera eye) +is empirically correct because acdream's camera doesn't yet have full collision. The +retail-faithful end state requires a camera cell transition sweep. + +**Q: Should the render obey a single portal-visibility traversal?** +Yes. Retail's `PView::ConstructView` + `DrawCells` is a single BFS that produces the +complete `cell_draw_list` and `outside_view` in one pass. There is no separate +"inside pass" + "outside pass" split. The outdoor terrain draws INSIDE `DrawCells` when +`outside_view.view_count > 0` (i.e., exit portals were traversed). acdream's two-pipe +architecture (WorldBuilder `RenderInsideOut` stencil + outdoor terrain) is the source +of the seam bugs and should be replaced with a faithful PView BFS. + +### D16. Must-port functions, integration order, risks, conformance tests + +#### Must-port functions (with retail addresses) + +| Priority | Function | Address | Purpose | +|----------|---------- |---------|---------| +| **P1** | `CTransition::validate_transition` | `0x0050aa70` | Accept-or-reject gate: advances `curr_cell` on accept, resets on reject | +| **P1** | `CObjCell::find_cell_list` | `0x0052b4e0` | Candidate cell array + containing-cell detection | +| **P1** | `CPhysicsObj::SetPositionInternal` | `0x00515330` | Write-back: reads `sphere_path.curr_cell` → `change_cell` | +| **P2** | `CTransition::check_other_cells` | `0x0050ae50` | Cross-cell collision + updates `check_cell` from `find_cell_list` | +| **P2** | `CEnvCell::find_transit_cells` | `0x0052c820` | Expand cell array through portals (the EnvCell variant) | +| **P2** | `CLandCell::find_transit_cells` | `0x00533800` | Expand cell array from outdoor cells | +| **P2** | `CEnvCell::check_building_transit` | `0x0052c5d0` | Detect sphere entering building through CBldPortal | +| **P3** | `PView::ConstructView` | `0x005a57b0` | Render BFS: builds cell_draw_list from viewer_cell | +| **P3** | `PView::InitCell` | `0x005a4b70` | Initialize per-portal vis state for root cell | +| **P3** | `PView::DrawCells` | `0x005a4840` | Execute the draw list: terrain if outside_view > 0 | +| **P3** | `SmartBox::update_viewer` | `0x00453ce0` | Camera cell tracking via its own transition sweep | +| **P4** | `CEnvCell::find_visible_child_cell` | `0x0052dc50` | Find which portal-neighbor contains a point (used by camera placement) | + +#### Integration order (recommended) + +**Step 1 — Physics cell tracking (P1, eliminates the flicker bug):** +In `PhysicsEngine.ResolveWithTransition`, after `transition.FindTransitionalPosition()` +returns, read `transition.SpherePath.CurCell` directly. This is retail's `sphere_path.curr_cell` +after `validate_transition` has run. Do NOT call `ResolveCellId(sp.GlobalSphere[0].Origin, ...)`. + +Specifically, in PhysicsEngine.cs: +- Line ~909: replace `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` with direct + `sp.CurCell?.ID ?? sp.CheckCellId` +- Same at line ~928 (the partial-move path) +- Add logic analogous to `SetPositionInternal`: if `transitCell == null`, treat as + "left visibility"; if `transitCell != null`, use it directly. + +This is the highest-priority change. It should IMMEDIATELY stop the ping-pong because +`CurCell` only changes when `validate_transition` accepts a step. + +**Step 2 — `do_not_load_cells` prune for teleports (P1 followup):** +For the `CheckBuildingTransit` and teleport placement paths, set `do_not_load_cells = 1` +in the `CELLARRAY` before calling `find_cell_list`. The prune logic (remove cells not in +`visible.stab_list`) prevents spurious cross-wall candidates during placements. + +**Step 3 — Camera cell tracking (P3, for render correctness):** +Port `SmartBox::update_viewer`'s camera transition sweep. This is a separate +`CTransition::find_valid_position` call on a small `viewer_sphere` from the player +position to the camera-sought position. The result's `sphere_path.curr_cell` becomes +the PView root (`viewer_cell`). acdream's `PhysicsCameraCollisionProbe` and +`RetailChaseCamera` are the existing hooks for this. + +**Step 4 — PView BFS render traversal (P3, eliminates indoor seam bugs):** +Replace the current WorldBuilder `RenderInsideOut` stencil + outdoor draw split with a +faithful `PView::ConstructView` BFS producing a `cell_draw_list`. Gate terrain draw on +`outside_view.view_count > 0`. Gate entity/particle draws on cell membership in +`cell_draw_list`. + +#### Main risks + +**Risk 1 — The sweep's `CurCell` may be null in edge cases.** +Retail's `SetPositionInternal` handles `curr_cell == null` as "leave visibility". acdream +must add the same null-guard. If the transition has zero steps (no movement), `CurCell` +should be pre-seeded from `PhysicsBody.CellId` at transition init (the `begin_cell`). +Failure mode: null-ref crash on first frame of movement. + +**Risk 2 — `check_cell` vs `curr_cell` confusion in `check_other_cells`.** +`check_other_cells` sets `check_cell` (NOT `curr_cell`) to the `find_cell_list` result +(pseudo_c:272761). `curr_cell` only advances in `validate_transition`. If Step 1 reads +from the wrong field, the flicker comes back in a different form. + +**Risk 3 — Cellar-ramp issue (#98) may be a separate bug.** +The stale contact-plane hypothesis (CLAUDE.md) suggests the cellar-ascent bug is a +separate issue (stale ramp contact plane causing spurious Z-drift, not a cell tracking bug). +Step 1 does NOT fix that; it only eliminates the doorway ping-pong. Keeping issues +separate is important. + +**Risk 4 — The `do_not_load_cells` prune and multi-cell BSP.** +The prune uses the CURRENT visible cell's `stab_list`. If acdream hasn't loaded all +stab-list cells, the prune may incorrectly remove valid neighbor cells. Implement the +prune only when `stab_list` is guaranteed to be fully populated (after `grab_visible_cells`). + +**Risk 5 — WorldBuilder rendering infrastructure vs PView.** +acdream's entire renderer is built on WB's model. Porting PView from scratch is a +large change. The safer incremental path: keep WB infrastructure but replace the +`camera_inside_building` two-pipe split with a single PView-BFS-driven `cell_draw_list` +that controls which draw passes run. + +**Risk 6 — `CObjCell::seen_outside` field in acdream's data layer.** +The `seen_outside` flag must be populated from the DAT (it is serialized in `CEnvCell::UnPack` +at pseudo_c:311044–311057). Verify that acdream's `EnvCell` data class carries and surfaces +this field. If not, dungeon vs indoor vs outdoor classification cannot be retail-faithful. + +#### Conformance tests + +1. **Ping-pong test:** Place the physics sphere at the exact doorway boundary (8cm inside + the indoor cell). Run 100 ticks of zero-movement resolve. Assert `CurrCell` does NOT + change across ticks. (Currently fails with acdream's static re-derive.) + +2. **Doorway crossing test:** Move sphere from outdoor cell through a door into the indoor + cell. Assert `CurrCell` transitions exactly once — not on the first frame the sphere + overlaps the door frame, but on the first frame `validate_transition` accepts a step + placing the sphere center inside the indoor cell's BSP. + +3. **Blocked-step stability:** Set up a sphere pressed against a wall (collision returns + non-OK). Run 10 ticks. Assert `CurrCell` never changes. + +4. **Dungeon no-terrain test:** Place player in a dungeon cell with `seen_outside = 0`. + Assert that the render pass does NOT draw terrain (no `LScape::draw` call or equivalent). + +5. **Exit-portal terrain test:** Place player in an indoor cell that has an exit portal + (`seen_outside != 0`). Assert that terrain IS drawn, clipped to the portal opening. + +6. **`do_not_load_cells` prune test:** Teleport a physics body to a position inside an + EnvCell whose PVS does not include an adjacent cell. Assert the adjacent cell's ID + does NOT appear in the collision `CELLARRAY`. + +7. **Camera cell tracking:** Move the camera to a position inside a building (3rd-person + mode). Assert `viewer_cell` equals a valid indoor `CEnvCell`. Move camera back to + outdoor. Assert `viewer_cell` becomes null/landcell. + +--- + +## Appendix: Key Function Summary Table + +| Function | Address | Key behavior | +|----------|---------|-------------| +| `CObjCell::find_cell_list` | `0x0052b4e0` | Build candidate array + pick containing cell via point_in_cell | +| `CEnvCell::find_transit_cells` | `0x0052c820` | Expand array through CCellPortal (EnvCell-to-EnvCell) | +| `CEnvCell::check_building_transit` | `0x0052c5d0` | Detect sphere entering building via CBldPortal sphere test | +| `CTransition::check_other_cells` | `0x0050ae50` | Find containing cell after primary insert; update check_cell | +| `CTransition::validate_transition` | `0x0050aa70` | Accept step: curr_cell=check_cell; reject: reset check_pos | +| `CTransition::transitional_insert` | `0x0050b6f0` | Outer sweep loop driving insert_into_cell + check_other_cells | +| `CPhysicsObj::SetPositionInternal` | `0x00515330` | Write-back: sphere_path.curr_cell → change_cell | +| `CPhysicsObj::change_cell` | `0x00513390` | leave_cell(old) + enter_cell(new) | +| `CPhysicsObj::enter_cell` | `0x00510ed0` | Register obj in new cell's object_list; update cell pointer | +| `CPhysicsObj::leave_cell` | `0x00510f50` | Deregister obj from old cell's object_list | +| `CellManager::ChangePosition` | `0x004559b0` | High-level: update curr_cell + trigger landscape grab/release | +| `SmartBox::update_viewer` | `0x00453ce0` | Camera cell tracking via own CTransition sweep | +| `SmartBox::RenderNormalMode` | `0x00453aa0` | Master render gate: DrawInside vs LScape::draw on seen_outside | +| `PView::ConstructView` | `0x005a57b0` | BFS from viewer_cell: builds cell_draw_list + outside_view | +| `PView::InitCell` | `0x005a4b70` | Init per-portal vis masks for root cell | +| `PView::DrawCells` | `0x005a4840` | Execute draw list; terrain if outside_view.view_count > 0 | +| `CEnvCell::GetVisible` | `0x0052dc10` | Lookup cell by id in visible_cell_table (loaded cells only) | +| `CEnvCell::find_visible_child_cell` | `0x0052dc50` | Find portal-neighbor containing a given point | +| `CEnvCell::grab_visible_cells` | `0x0052e220` | Load self + stab_list; conditionally grab landscape | +| `CObjCell::GetVisible` | `0x0052ad40` | Dispatch to CEnvCell::GetVisible or CLandCell::GetVisible | +| `CLandCell::GetVisible` | `0x00532db0` | Lookup landcell by id | +| `CLandCell::add_all_outside_cells` | `0x00533630` | Add all surrounding landcells to CELLARRAY (for outdoor) | + +--- + +## Cross-references + +- **ACE** `PhysicsObj.cs:1171–1211` confirms `SetPositionInternal` reads `SpherePath.CurCell` + directly (not via position re-derive). ACE `ObjCell.cs:335–413` confirms the `find_cell_list` + logic including `do_not_load_cells` (`LoadCells` flag in ACE, CellArray.cs:8). + ACE `Transition.cs:984–1091` (`ValidateTransition`) matches the retail decomp's + curr_cell / check_cell advance/reset pattern exactly. +- **WorldBuilder** `PortalRenderManager.cs` uses a flat stencil approach (not PView BFS) — + this is a confirmed divergence from retail's recursive portal clip system. +- **acdream** `PhysicsEngine.cs:909/928`: the two `ResolveCellId(sp.GlobalSphere[0].Origin, ...)` + calls are the specific lines to replace with `sp.CurCell` reads. +- **acdream** `GameWindow.cs:7163`: the W2 UCG fix (using physics `CurrCell` as PView root) + is directionally correct and should be kept, but note it is the player-cell root, not + the retail camera-cell root. diff --git a/docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md b/docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md new file mode 100644 index 0000000..963b26c --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md @@ -0,0 +1,165 @@ +# Phase W (rev) — Transition-owned cell membership + PView render pass + +**Status:** design approved 2026-06-02 (user: "go ahead … thorough rewrite"). Supersedes the +W2b approach (stab-list prune inside the static `ResolveCellId`) and confirms the A8 two-pipe +abandonment. Behavior-changing across physics + render → visual verification at the Holtburg +cottage + a dungeon is the acceptance gate. + +**Grounding:** four independent decomp studies (2026-06-02) — Opus 4.8 ×2, Sonnet 4.6, and an +external Codex pass — converge with no material contradiction. Reports: +`docs/research/2026-06-02-retail-cell-render-study-{opus48-a,opus48-b,sonnet46,codex}.md`; +shared brief: `docs/research/2026-06-02-retail-cell-render-research-prompt.md`. Verified against +live acdream code this session. + +--- + +## 1. Root cause (verified, unanimous) + +Retail never re-derives the player's cell from a static resting position. It **carries the cell +through the collision sweep** and commits it afterward: + +- `CTransition::validate_transition` @ `0x0050aa70` (pseudo_c:272547-272619): advances + `sphere_path.curr_cell = check_cell` **only on an accepted, moved sub-step** (`:272608-272619`); + on a block/slide it **restores** `check_cell ← curr_cell` and returns OK (`:272593`). A + push-back or standing still therefore **cannot** change the cell. +- `check_cell` is supplied during `CTransition::check_other_cells` @ `0x0050ae50` + (pseudo_c:272717) → `CObjCell::find_cell_list` @ `0x0052b4e0` (pseudo_c:308742), which builds + the candidate `CELLARRAY` (current + portal/transit neighbors + outside cells), picks the + first **interior** cell whose `point_in_cell` contains the sphere center, and applies the + `do_not_load_cells` prune (`:308829-308867`). +- `CPhysicsObj::SetPositionInternal(CTransition*)` @ `0x00515330` (pseudo_c:283399-283462) + reads `sphere_path.curr_cell` and calls `change_cell` @ `0x00513390` **only when it differs**. + +**acdream already ports the sweep machinery faithfully** — `SpherePath.CurCellId`/`CheckCellId` +exist; `Transition.ValidateTransition` sets `sp.CurCellId = sp.CheckCellId` on accept and +reverts on block (`TransitionTypes.cs:3404-3434`); `CheckOtherCells` retargets `CheckCellId` +after a successful other-cell query (`TransitionTypes.cs:2061-2075`, `:1949`). **The defect is +the consumer:** `PhysicsEngine.ResolveWithTransition` discards the swept `sp.CurCellId` and +re-derives the cell from the static sphere origin via `ResolveCellId(sp.GlobalSphere[0].Origin, +…)` on both the OK and partial paths (`PhysicsEngine.cs:909` / `:928`). As the collision +push-back jitters the origin ±~8 cm across a boundary, that static re-derive flips — the entire +`0170↔0031` (and `0170↔0171`, cellar) ping-pong. + +**Corollary (Codex):** the `do_not_load_cells` prune is a **secondary** parity/stability feature +for static/cross-cell lists (`calc_cross_cells_static` @ `0x00515160`), **not** the per-tick +anti-flicker mechanism. The load-bearing invariant is transition-owned membership. So the +membership-return fix alone is expected to stop the flicker; the prune is correctness we add +after. (This makes the shipped W2b doubly wrong — wrong location *and* wrong mechanism — and it +is reverted.) + +## 2. Target architecture (retail-faithful) + +**One cell graph, one membership authority, render obeys it.** + +1. **Membership is transition-owned.** `ResolveWithTransition` returns the swept `sp.CurCellId` + (mirroring `SetPositionInternal`), not a post-sweep static re-derive. `ResolveCellId` is + **demoted** to the seed-only cases that have no transition: spawn / teleport / server cell + set. The render reads this single membership answer (`CellGraph.CurrCell`). +2. **`CellTransit` gains `CELLARRAY` parity:** `AddedOutside` / `DoNotLoadCells`, candidate + list, and the `find_cell_list` `do_not_load_cells` prune + interior-wins pick. Applied when + building no-load/static lists from an EnvCell origin. (Correctness/parity — not the flicker + fix.) The `FindTransitCellsSphere` unconditional `exitOutside=true` (`CellTransit.cs:95-123`, + A6.P5) is re-gated to retail's portal-plane/sphere test (it currently over-admits outside). +3. **Render roots at the physics current cell + `seen_outside`**, not an independent AABB + `FindCameraCell`. Mirrors `CellManager::ChangePosition` @ `0x004559b0` (pseudo_c:94601-94682): + landscape/sunlight/outdoor-ambient stay live iff the current cell is a landcell OR + `CObjCell::seen_outside` (acclient.h:30929) is set; otherwise landscape is released and + indoor ambient is used. The 3rd-person camera offset uses a graph/BSP child lookup + (`CEnvCell::find_visible_child_cell` @ `0x0052dc50`), rooted at the **player** cell (preserves + the U.4c flap fix), never an AABB reclassification. +4. **One PView portal traversal** replaces the indoor/outdoor split + stencil pipeline. Port + `PView::ConstructView` (`0x005a57b0` EnvCell / `0x005a59a0` CBldPortal), `InitCell` + (`0x005a4b70`), `AddToCell` (`0x005a4d90`), `AddViewToPortals` (`0x005a52d0`), `DrawPortal` + (`0x005a5ab0`), `DrawCells` (`0x005a4840`). It yields a `cell_draw_list` + a single + `OutsideView` from one BFS. Convergence is governed by per-cell `portal_view_type.update_count` + watermarks + a todo list (NOT a fixed cap — this replaces `PortalVisibilityBuilder + .MaxReprocessPerCell = 4` and closes #102). When `OutsideView.view_count > 0` (an exit portal + was traversed), `DrawCells` draws `LScape` (terrain/sky/rain) **clipped to the doorway** with + a conditional depth clear — so the outside is visible through the door and there is **no blue + clear-color hole**, by construction. No separate inside/outside stencil pass (WorldBuilder's + `RenderInsideOut` and ACViewer's brute-force are reference-divergent; the decomp wins). +5. **Entities/particles clip to the visible cell set** (`find_visible_child_cell` + the PView + draw list), not the world frustum — kills NPC/door/smoke bleed-through. + +**Dungeons/underground are emergent, not flagged.** Indoors = current cell is an EnvCell +(`id & 0xFFFF ≥ 0x100`). "Underground" = an EnvCell with no `seen_outside` and no exit-portal +reachability, on a landblock with no meaningful terrain. There is no client `underground` +boolean. Pure-dungeon landblocks (all terrain heights 0, EnvCells, no buildings — ACE's +`IsDungeon` heuristic) simply have no landscape to draw; building interiors/cellars with +`seen_outside` still draw the clipped outdoor view through portals. Same transition + same PView +machinery for both. + +## 3. Staged plan (evidence-first, lowest-risk first; each behavior-changing stage is visual-gated) + +- **Stage 0 — Diagnostic (zero behavior change).** Add a probe logging, per + `ResolveWithTransition`, the swept `sp.CurCellId`/`CheckCellId` **alongside** the value the + static `ResolveCellId` would return. Launch, walk the doorway/cellar. **Prove the swept cell is + stable where the static one strobes.** If the swept cell is *also* tainted (the `exitOutside` + over-admission), do Stage 2's `FindTransitCellsSphere` re-gating first. Gate: evidence in hand. +- **Stage 1 — Transition-owned membership (behavior-changing).** Return `sp.CurCellId` from + `ResolveWithTransition`; demote the static `ResolveCellId` to the spawn/teleport/server-set + seed path only. Revert W2b (`2acd8f9`: `DoorwayHoldMargin` + the prune-in-`ResolveCellId`). + **Visual gate: the `0170↔0031` / `0170↔0171` / cellar strobe stops; collision unchanged.** +- **Stage 2 — `CELLARRAY` parity + prune (correctness).** Port `AddedOutside`/`DoNotLoadCells` + + the `do_not_load_cells` prune + interior-wins pick into `CellTransit`; re-gate the `exitOutside` + over-admission. Conformance tests against cottage/cellar/dungeon golden candidate sets. +- **Stage 3 — Render root unification (behavior-changing).** Root render visibility at + `CellGraph.CurrCell` + `seen_outside`; remove the `ACDREAM_A8_INDOOR_BRANCH` split, the AABB + `FindCameraCell` + grace-frame hack, and `RenderInsideOutAcdream` stencil path. Camera offset + via child-cell lookup. **Visual gate: no render-branch strobe; landscape policy correct + indoors/outdoors/dungeon.** +- **Stage 4 — PView traversal + seamless seal (behavior-changing, the big one).** Implement the + PView BFS (`update_count` watermark) producing `cell_draw_list` + `OutsideView`; draw landscape + through the doorway; cap ceilings; fold in the `EnvCellRenderer` inherited-`GL_BLEND` fix + (memory `render-self-contained-gl-state`). **Visual gate: interior sealed, outside visible + through the door (sky/rain), no blue-hole, no transparent walls.** +- **Stage 5 — Entity/particle cell clipping (behavior-changing).** Clip entities/particles to the + PView visible set. **Visual gate: no NPC/door/smoke bleed-through; entities through a doorway + remain visible.** + +## 4. Must-port / align (decomp addresses) + +| Area | Functions | +|---|---| +| Membership commit | `validate_transition 0x0050aa70`, `SetPositionInternal 0x00515330`, `change_cell 0x00513390` | +| Cell array | `find_cell_list 0x0052b4e0`, `CEnvCell::find_transit_cells 0x0052c820`, `CLandCell::add_all_outside_cells 0x00533630`, `CELLARRAY::add_cell 0x006b4ff0`, `remove_cell 0x006b4e80`, `calc_cross_cells_static 0x00515160` | +| Render root | `CellManager::ChangePosition 0x004559b0`, `SmartBox::is_player_outside 0x00451e80`, `Position::get_outside_cell_id 0x004527b0`, `find_visible_child_cell 0x0052dc50` | +| PView | `ConstructView 0x005a57b0`/`0x005a59a0`, `InitCell 0x005a4b70`, `AddToCell 0x005a4d90`, `AddViewToPortals 0x005a52d0`, `DrawPortal 0x005a5ab0`, `DrawCells 0x005a4840` | +| Structs (acclient.h) | `SPHEREPATH:32625`, `CObjCell:30915`, `CEnvCell:32072`, `CLandCell:31886`, `CCellPortal:32300`, `CBldPortal:32094`, `CELLARRAY:31574`, `portal_view_type:32346`, `seen_outside @ 30929` | + +Cross-checks: ACE `Transition.cs:984`, `PhysicsObj.cs:1171`, `ObjCell.cs:335`, `EnvCell.cs:311`, +`CellArray.cs:17`; ACViewer render; WorldBuilder `PortalService`/`VisibilityManager` (reference +base for the Silk.NET implementation, NOT the algorithm). + +## 5. Risks + +- **Verify-before-delete (Codex):** prove the swept cell is correct before removing the static + re-derive (Stage 0). Keep `ResolveCellId` for the seed-only path. +- **`exitOutside` over-admission** (`CellTransit.cs:95-123`): could taint the swept cell; re-gate + in Stage 2 (or Stage 0 if Stage-0 evidence shows taint). +- **Collision regression (#98 area):** Stage 1 must NOT touch collision math (only the returned + cell id). Lean on the existing #98 fixtures + cottage-floor-cap regression test. +- **Render regression:** build the PView traversal as a frame *product* and diff it against the + current `PortalVisibilityBuilder` output before changing draw order (Stage 4). +- **Dungeon PVS blowup (#95):** watch the `update_count` convergence on dungeon graphs; the + watermark/todo model is the retail fix but validate on a real dungeon. +- **Dungeon vs cottage QA divergence:** pure dungeons draw no terrain; `seen_outside` cellars do. + +## 6. Conformance / acceptance + +- Unit/replay: doorway-stationary "zero cell changes" test at `0xA9B40170↔0xA9B40031`; + room↔vestibule and cellar-ramp-top cell-change-only-on-accepted-move tests; blocked-wall + no-cell-change test; golden `CELLARRAY` candidate sets (cottage door, cellar exit, interior + portal, building entry); PView convergence test (a cell receiving multiple clipped slices); + doorway `OutsideView` non-empty + sealed-cellar `OutsideView`-empty tests. +- Visual (decisive, the user): cottage — flicker gone (Stage 1), interior sealed with sky/rain + through the door, no blue-hole, no transparent walls, no bleed-through (Stages 4-5); a real + dungeon — sealed, no terrain, traversal converges without blowup. +- `dotnet build` green; no NEW deterministic test failures vs the documented static-leak baseline. + +## 7. Task decomposition → plan +Stages 0-5 above map to plan tasks. Stages 0-2 (membership + parity) are one coherent chunk with +a visual gate after Stage 1. Stages 3-5 (render) are the second chunk; Stage 4 (PView) likely +warrants its own detailed sub-spec written after the Stage-1 visual gate confirms the membership +foundation. W2a (`CellGraph.CurrCell` + `ComputeVisibilityFromRoot`) is kept and built upon; W2b +is reverted.