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>
278 lines
37 KiB
Markdown
278 lines
37 KiB
Markdown
# 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`.
|