docs(render): Phase W (rev) — 4-model research + transition-membership/PView design
Four independent decomp studies (Opus 4.8 x2, Sonnet 4.6, external Codex) converge: retail carries the cell through the collision sweep (validate_transition advances curr_cell only on an accepted move, reverts on a block) and commits it in SetPositionInternal — it never re-derives membership from a static resting position. acdream already ports the sweep machinery (sp.CurCellId/CheckCellId, ValidateTransition, CheckOtherCells) but ResolveWithTransition discards the swept cell and re-derives statically via ResolveCellId (PhysicsEngine.cs:909/928) — the root of the 0170<->0031 doorway/cellar ping-pong. The do_not_load_cells prune is secondary (static/cross-cell lists), not the anti-flicker; W2b was doubly misplaced and is reverted. Render: one PView::ConstructView portal traversal over the same cell graph, rooted at the physics current cell; seen_outside (not a dungeon flag) gates landscape; the outside draws through exit portals clipped to the doorway (no blue-hole, no stencil split). Dungeons/interiors share the machinery; "underground" is emergent. Design doc lays out the staged, evidence-first rewrite (Stage 0 diagnostic -> Stage 1 transition-owned membership [visual gate] -> Stage 2 CELLARRAY/prune parity -> Stages 3-5 render root + PView seal + entity clip). Adds the shared research prompt and all four study reports as the grounding record. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2acd8f9e1d
commit
840c1b6442
6 changed files with 3608 additions and 0 deletions
129
docs/research/2026-06-02-retail-cell-render-research-prompt.md
Normal file
129
docs/research/2026-06-02-retail-cell-render-research-prompt.md
Normal file
|
|
@ -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-<model>.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`.
|
||||
278
docs/research/2026-06-02-retail-cell-render-study-codex.md
Normal file
278
docs/research/2026-06-02-retail-cell-render-study-codex.md
Normal file
|
|
@ -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_type*> 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`.
|
||||
1331
docs/research/2026-06-02-retail-cell-render-study-opus48-a.md
Normal file
1331
docs/research/2026-06-02-retail-cell-render-study-opus48-a.md
Normal file
File diff suppressed because it is too large
Load diff
894
docs/research/2026-06-02-retail-cell-render-study-opus48-b.md
Normal file
894
docs/research/2026-06-02-retail-cell-render-study-opus48-b.md
Normal file
|
|
@ -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<CELLINFO> 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<LoadedCell>`, 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.
|
||||
811
docs/research/2026-06-02-retail-cell-render-study-sonnet46.md
Normal file
811
docs/research/2026-06-02-retail-cell-render-study-sonnet46.md
Normal file
|
|
@ -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<CELLINFO> 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<CEnvCell*>`, 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue