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>
1331 lines
70 KiB
Markdown
1331 lines
70 KiB
Markdown
# Retail AC — cell transitions, underground/dungeons, and seamless inside/outside rendering
|
||
|
||
> **Independent decomp study (model: Opus 4.8, variant A). 2026-06-02.**
|
||
> Primary oracle: the named retail decomp at `docs/research/named-retail/`
|
||
> (`acclient_2013_pseudo_c.txt`, `acclient.h`, `symbols.json`). Cross-checked
|
||
> against `references/ACE` (C# physics port), `references/ACViewer` (MonoGame
|
||
> viewer), `references/WorldBuilder`. Citations are `function @ 0xADDR
|
||
> (pseudo_c:LINE)` for decomp and `repo/path:LINE` for references. Each
|
||
> non-trivial claim is tagged **[VERIFIED]** (read in source) or **[INFERRED]**.
|
||
|
||
---
|
||
|
||
## TL;DR (headline findings)
|
||
|
||
1. **Retail never re-derives the cell from the final XYZ.** It *tracks* the
|
||
current cell (`sphere_path.curr_cell`) **through the collision sweep**. The
|
||
advance happens in `CTransition::validate_transition` on an accepted
|
||
sub-step (`curr_cell = check_cell`), and a **blocked/standing-still
|
||
sub-step explicitly resets `check_cell` back to `curr_cell`** — so a
|
||
push-back can never change which cell you are in. At the end of the tick,
|
||
`CPhysicsObj::SetPositionInternal(CTransition*)` reads
|
||
`transition.sphere_path.curr_cell` and calls `change_cell` **only when it
|
||
differs** from the held cell. This is the structural cure for the flicker.
|
||
|
||
2. **`find_cell_list` builds a candidate cell array (containment + portal
|
||
neighbours), picks the *single* containing cell — interior cells win and
|
||
short-circuit — and then applies the `do_not_load_cells` prune** which
|
||
removes every candidate that is neither the current cell nor in the current
|
||
interior cell's static visibility list (`stab_list`). The prune is what
|
||
buys stability for interior membership: it stops far/irrelevant cells from
|
||
ever becoming the answer.
|
||
|
||
3. **Underground/dungeons are NOT a special mode.** There is no "underground"
|
||
flag in the retail client physics or render path. A dungeon is simply a
|
||
landblock whose terrain heights are all zero, which has ≥1 EnvCell and no
|
||
buildings — so you are always inside an `EnvCell` whose `seen_outside == 0`,
|
||
and the renderer therefore never draws terrain or sky. "Inside a building"
|
||
differs only in that the EnvCell sits on a real terrain landblock and
|
||
(often) has `seen_outside == 1`, so the landscape is still drawn through
|
||
the door.
|
||
|
||
4. **Retail renders inside+outside in ONE portal-visibility traversal
|
||
(`PView`).** `SmartBox::RenderNormalMode` makes a single decision per frame
|
||
from `viewer_cell` (an outdoor landcell → draw the landscape; an EnvCell →
|
||
`DrawInside`). `PView::ConstructView` does a breadth-first portal walk that
|
||
produces an ordered visible cell list (`cell_draw_list`) plus per-portal
|
||
screen clip regions. When a portal's `other_cell_id == 0xffffffff` (an exit
|
||
portal to the outdoors) it pulls the **landscape into the same traversal,
|
||
clipped to the doorway**, so there is no blue clear-color hole; sky/rain/
|
||
terrain show through. The depth clear is conditional, not an unconditional
|
||
blue fill.
|
||
|
||
5. **Render and physics use the SAME cell graph and the SAME `objcell_id`.**
|
||
The render-side `CellManager::ChangePosition` resolves its `curr_cell` via
|
||
`CObjCell::Get(position.objcell_id)` — the very same id physics maintains.
|
||
`seen_outside` is a per-cell dat flag (`EnvCellFlags.SeenOutside = 0x1`).
|
||
Both the physics cell array and the render PVS traverse cells via the same
|
||
`CEnvCell::portals` / `stab_list` / `GetVisible` machinery (just different
|
||
BSP trees inside the same `CCellStruct`: `physics_bsp`/`cell_bsp` vs
|
||
`drawing_bsp`).
|
||
|
||
6. **acdream's bug is localized and the fix surface already exists.**
|
||
acdream's `SpherePath` already tracks `CurCellId`/`CheckCellId` and its
|
||
`ValidateTransition` already advances/resets them correctly
|
||
(`TransitionTypes.cs:3398-3434`). The *only* defect is at the engine
|
||
output: `ResolveWithTransition` (`PhysicsEngine.cs:909` and `:928`) throws
|
||
away `sp.CurCellId` and **re-derives the cell statically** via
|
||
`ResolveCellId(sp.GlobalSphere[0].Origin, …)`. Plus there is no
|
||
`do_not_load_cells` prune in `CellTransit`, and the render maintains a
|
||
*separate* cell system (`CellVisibility.FindCameraCell` with a 3-frame
|
||
grace-frame band-aid). The unified W1 `CellGraph` (`CurrCell` +
|
||
`GetVisible`) is the seam to land all three fixes on.
|
||
|
||
---
|
||
|
||
# A. Cell membership & transitions (physics)
|
||
|
||
## A0. The data model: `SPHEREPATH` and `CObjCell`
|
||
|
||
The single source of truth for "the cell I'm in" during a move is
|
||
`SPHEREPATH::curr_cell`. The struct (verbatim retail header) is:
|
||
|
||
`acclient.h:32625` — `struct SPHEREPATH` **[VERIFIED]**:
|
||
```c
|
||
struct SPHEREPATH {
|
||
...
|
||
CObjCell *begin_cell; // where the sweep starts (== object's current cell)
|
||
Position *begin_pos;
|
||
Position *end_pos; // requested destination
|
||
CObjCell *curr_cell; // ACCEPTED cell — the committed membership answer
|
||
Position curr_pos; // ACCEPTED position
|
||
...
|
||
CObjCell *check_cell; // CANDIDATE cell being tested this sub-step
|
||
Position check_pos; // CANDIDATE position
|
||
SPHEREPATH::InsertType insert_type; // TRANSITION_INSERT / PLACEMENT_INSERT
|
||
...
|
||
CObjCell *backup_cell; // saved for restore (step-up/walkable probes)
|
||
Position backup_check_pos;
|
||
int hits_interior_cell; // set when an interior cell was added/contained
|
||
int cell_array_valid;
|
||
...
|
||
};
|
||
```
|
||
|
||
The cell objects themselves (`acclient.h:30915` `CObjCell`, `:32072`
|
||
`CEnvCell`, `:31886` `CLandCell`) **[VERIFIED]**:
|
||
|
||
```c
|
||
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
|
||
LandDefs::WaterType water_type;
|
||
Position pos; // cell-to-world frame
|
||
...
|
||
unsigned int num_shadow_objects;
|
||
DArray<CShadowObj *> shadow_object_list; // collision objects registered in this cell
|
||
unsigned int restriction_obj;
|
||
...
|
||
unsigned int num_stabs;
|
||
unsigned int *stab_list; // STATIC visibility set: cell ids this cell can see
|
||
int seen_outside; // boolean: this interior can see the outdoors
|
||
...
|
||
CLandBlock *myLandBlock_;
|
||
};
|
||
|
||
struct CEnvCell : CObjCell {
|
||
...
|
||
CCellStruct *structure; // geometry: vertices, polys, 3 BSP trees
|
||
unsigned int num_portals;
|
||
CCellPortal *portals; // portal graph edges
|
||
unsigned int num_static_objects;
|
||
...
|
||
unsigned int num_view;
|
||
DArray<portal_view_type *> portal_view; // RENDER per-portal clip state
|
||
};
|
||
|
||
struct CLandCell : CSortCell { // CSortCell carries `building` (CBuildingObj*)
|
||
CPolygon **polygons;
|
||
BoundingType in_view;
|
||
};
|
||
```
|
||
|
||
Key takeaways for membership:
|
||
- **`curr_cell` ≠ "the cell array".** `curr_cell` is *one* cell (membership).
|
||
`cell_array` is the multi-cell set the collision sweep queries (see A5).
|
||
- **`stab_list`** is a precomputed per-cell visibility list (the "stabs").
|
||
It is used by both the physics prune (A2) and the render PVS (C9).
|
||
- **`seen_outside`** is a per-cell boolean meaning "this interior cell has an
|
||
exterior portal / can see outdoors." (Confirmed as dat flag
|
||
`EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7`). **[VERIFIED]**
|
||
|
||
## A1. The full membership chain (per tick)
|
||
|
||
The per-tick movement flows:
|
||
`UpdateObjectInternal → transition → find_valid_position → find_transitional_position → (loop) transitional_insert + validate_transition → SetPositionInternal → change_cell`.
|
||
|
||
### Step 1 — seed `curr_cell` from the object's current cell
|
||
|
||
`CPhysicsObj::transition @ 0x00512dc0 (pseudo_c:280904)` **[VERIFIED]** builds a
|
||
fresh `CTransition`, then at `:280939`:
|
||
```c
|
||
CTransition::init_path(result, this->cell, arg2 /*m_position*/, arg3 /*newPos*/);
|
||
```
|
||
`init_path → SPHEREPATH::init_path @ 0x0050ce20 (pseudo_c:274359)` **[VERIFIED]**
|
||
seeds:
|
||
```c
|
||
this->begin_cell = arg2; // == CPhysicsObj.cell
|
||
...
|
||
this->curr_pos.objcell_id = arg3->objcell_id; // begin pos
|
||
this->curr_cell = arg2; // curr_cell STARTS as the held cell (:274370)
|
||
this->insert_type = TRANSITION_INSERT;
|
||
```
|
||
So the sweep *begins* anchored to the cell you are already in.
|
||
|
||
### Step 2 — the sub-step loop advances `check_pos`, then validates
|
||
|
||
`CTransition::find_transitional_position @ 0x0050bdf0 (pseudo_c:273613)`
|
||
**[VERIFIED]** is the driver. It computes a step count (`calc_num_steps`), then
|
||
per sub-step (for `TRANSITION_INSERT`, `pseudo_c:273736-273755`):
|
||
```c
|
||
// advance the CANDIDATE position by the per-step offset
|
||
this->sphere_path.check_pos.frame.origin += global_offset; // :273739-273741
|
||
SPHEREPATH::cache_global_sphere(&sphere_path, &global_offset);
|
||
var_44 = CTransition::validate_transition(this,
|
||
CTransition::transitional_insert(this, 3), &var_40); // :273743
|
||
...
|
||
ebx += 1;
|
||
if (ebx >= var_48) goto done;
|
||
continue;
|
||
```
|
||
At the very end (when all sub-steps are consumed, `pseudo_c:273648-273651`):
|
||
```c
|
||
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell);
|
||
sphere_path.cell_array_valid = 1;
|
||
sphere_path.hits_interior_cell = 0;
|
||
CObjCell::find_cell_list(&this->cell_array, nullptr, &this->sphere_path); // rebuild final cell array
|
||
```
|
||
**Critical**: the final `find_cell_list` rebuilds the *collision* `cell_array`
|
||
around the final position, but the **membership answer is already in
|
||
`curr_cell`** (advanced by `validate_transition` during the loop). The final
|
||
`find_cell_list` is passed `arg5 = nullptr` (no `*currCell` out-param), so it
|
||
does NOT re-pick membership.
|
||
|
||
### Step 3 — `transitional_insert`: one sub-step of collision + cell discovery
|
||
|
||
`CTransition::transitional_insert @ 0x0050b6f0 (pseudo_c:273137)` **[VERIFIED]**
|
||
is the inner stepper. Per iteration:
|
||
```c
|
||
edi = CTransition::insert_into_cell(this, sphere_path.check_cell, eax_2); // collide in check_cell
|
||
switch (edi) {
|
||
case OK_TS:
|
||
edi = CTransition::check_other_cells(this, sphere_path.check_cell); // discover/cross cells
|
||
...
|
||
}
|
||
```
|
||
`insert_into_cell @ 0x00509e70 (pseudo_c:271991)` **[VERIFIED]** simply calls
|
||
`arg2->vtable->find_collisions(this)` (the cell's own collision query). On
|
||
`OK_TS`, `check_other_cells` runs.
|
||
|
||
### Step 4 — `check_other_cells`: cross into the right cell mid-sweep
|
||
|
||
`CTransition::check_other_cells @ 0x0050ae50 (pseudo_c:272717)` **[VERIFIED]**:
|
||
```c
|
||
sphere_path.cell_array_valid = 1;
|
||
sphere_path.hits_interior_cell = 0;
|
||
CObjCell::find_cell_list(&this->cell_array, &var_4c /*out: containing cell*/, &sphere_path);
|
||
for (i : cell_array) { // collide against every other cell
|
||
cell = cell_array.cells[i].cell;
|
||
if (cell && cell != arg2)
|
||
result = cell->vtable[+0x88](this); // find_collisions
|
||
if (result is COLLIDED/ADJUSTED/SLID/CP-clear) handle/return;
|
||
}
|
||
check_cell = var_4c; // RETARGET candidate cell to the containing cell
|
||
if (check_cell != 0) {
|
||
SPHEREPATH::adjust_check_pos(&sphere_path, check_cell->id); // rebase check_pos id to new cell
|
||
return result;
|
||
}
|
||
// --- no containing cell found: we have left the indoor graph ---
|
||
if (sphere_path.step_down) return COLLIDED_TS;
|
||
objcell_id = sphere_path.check_pos.objcell_id;
|
||
if (objcell_id < 0x100) { /* already outside */ }
|
||
else LandDefs::adjust_to_outside(objcell_id, &frame); // CONVERT interior id -> outdoor landcell id
|
||
if (objcell_id == 0) return COLLIDED_TS;
|
||
SPHEREPATH::adjust_check_pos(&sphere_path, objcell_id);
|
||
sphere_path.check_cell = nullptr; // will be resolved next find_cell_list
|
||
```
|
||
This is the **indoor→outdoor exit**: when the foot sphere no longer lands in
|
||
any portal-connected interior cell, `adjust_to_outside`
|
||
(`LandDefs::adjust_to_outside @ 0x005a9bc0 (pseudo_c:438719)` **[VERIFIED]**)
|
||
maps the interior cell id to the surrounding landblock's outdoor cell id, and
|
||
`check_cell` becomes the landcell on the next pass.
|
||
|
||
### Step 5 — `validate_transition`: ADVANCE on accept, RESET on block (the anti-flicker)
|
||
|
||
`CTransition::validate_transition @ 0x0050aa70 (pseudo_c:272547)` **[VERIFIED]**.
|
||
The decisive control flow:
|
||
|
||
```c
|
||
if (transitionState != OK || check_pos == curr_pos) // blocked OR no movement
|
||
{
|
||
if (transitionState != OK) {
|
||
if (transitionState != INVALID) { // a real block/slide/adjust
|
||
... (contact-plane / last-known-plane handling) ...
|
||
SPHEREPATH::set_check_pos(&sphere_path, &sphere_path.curr_pos, sphere_path.curr_cell); // :272593
|
||
// ^^^ RESET candidate back to curr_pos / curr_cell — cell does NOT change
|
||
CTransition::build_cell_array(this, nullptr);
|
||
transitionState = OK_TS;
|
||
}
|
||
} else {
|
||
// check_pos == curr_pos: accept-no-move => label_50aba9 advances (no-op)
|
||
}
|
||
}
|
||
else // OK and we moved
|
||
{
|
||
label_50aba9: // :272608
|
||
check_cell = sphere_path.check_cell;
|
||
sphere_path.curr_pos.objcell_id = sphere_path.check_pos.objcell_id;
|
||
sphere_path.curr_cell = check_cell; // :272612 ADVANCE membership
|
||
SPHEREPATH::cache_global_curr_center(&sphere_path);
|
||
// re-seed check_* = curr_* for the next sub-step
|
||
sphere_path.check_pos.objcell_id = sphere_path.curr_pos.objcell_id;
|
||
sphere_path.check_cell = sphere_path.curr_cell; // :272617
|
||
sphere_path.cell_array_valid = 0;
|
||
}
|
||
```
|
||
|
||
**The anti-flicker guarantee, stated precisely:**
|
||
- A sub-step that **moves and is accepted** (`OK_TS` && `check_pos != curr_pos`)
|
||
advances `curr_cell = check_cell` (the cell that the sweep crossed into).
|
||
- A sub-step that is **blocked/slid/adjusted** (not `OK_TS`) calls
|
||
`set_check_pos(curr_pos, curr_cell)` — it **rolls the candidate cell BACK to
|
||
the already-accepted cell**. Membership cannot change on a block.
|
||
- A sub-step that is **OK but does not move** (`check_pos == curr_pos`) takes
|
||
the no-op path; membership stays.
|
||
|
||
So push-back jitter — which is exactly a "block" producing a small reversal —
|
||
never changes `curr_cell`. The cell only changes when the swept path
|
||
*genuinely crosses* into a new cell and that crossing is accepted.
|
||
|
||
**ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Transition.cs:984`
|
||
`ValidateTransition`:
|
||
```csharp
|
||
if (transitionState != TransitionState.OK || SpherePath.CheckPos.Equals(SpherePath.CurPos)) {
|
||
...
|
||
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell); // line 1014 (block path: reset)
|
||
...
|
||
} else
|
||
SetCurrentCheckPos(); // line 1024 (accept path)
|
||
```
|
||
`SetCurrentCheckPos()` (`Transition.cs:1084`):
|
||
```csharp
|
||
SpherePath.CurPos = new Position(SpherePath.CheckPos);
|
||
SpherePath.CurCell = SpherePath.CheckCell; // ADVANCE (line 1087)
|
||
SpherePath.CacheGlobalCurrCenter();
|
||
SpherePath.SetCheckPos(SpherePath.CurPos, SpherePath.CurCell);
|
||
```
|
||
ACE matches the decomp exactly.
|
||
|
||
### Step 6 — commit: `SetPositionInternal` reads `curr_cell`, calls `change_cell` only on change
|
||
|
||
`CPhysicsObj::UpdateObjectInternal @ 0x005156b0 (pseudo_c:283611)` **[VERIFIED]**:
|
||
```c
|
||
class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); // :283673
|
||
if (eax_10 == 0) { ... no move ... }
|
||
else {
|
||
Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); // velocity
|
||
...
|
||
CPhysicsObj::SetPositionInternal(this, eax_10); // :283696
|
||
}
|
||
```
|
||
|
||
`CPhysicsObj::SetPositionInternal(CTransition*) @ 0x00515330 (pseudo_c:283399)`
|
||
**[VERIFIED]** — the membership commit:
|
||
```c
|
||
class CObjCell* curr_cell = arg2->sphere_path.curr_cell; // :283403 READ the swept cell
|
||
if (curr_cell == 0) { prepare_to_leave_visibility(); store_position(...); GotoLostCell(...); }
|
||
else {
|
||
if (this->cell == curr_cell) { // :283414 SAME cell
|
||
this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id; // just update id
|
||
// (also propagate id to part_array + children)
|
||
} else {
|
||
CPhysicsObj::change_cell(this, curr_cell); // :283456 CHANGE only on differ
|
||
}
|
||
CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame);
|
||
...
|
||
}
|
||
```
|
||
|
||
`CPhysicsObj::change_cell @ 0x00513390 (pseudo_c:281192)` **[VERIFIED]**:
|
||
```c
|
||
if (this->cell != 0) CPhysicsObj::leave_cell(this, 1); // remove from old cell's shadow list
|
||
if (arg2 != 0) { CPhysicsObj::enter_cell(this, arg2); return; } // add to new cell's shadow list
|
||
this->m_position.objcell_id = 0; this->cell = nullptr;
|
||
```
|
||
|
||
**ACE cross-check [VERIFIED]** — `PhysicsObj.cs:1171` `SetPositionInternal(Transition)`:
|
||
```csharp
|
||
var transitCell = transition.SpherePath.CurCell; // line 1174 READ swept cell
|
||
...
|
||
if (transitCell.Equals(CurCell)) { Position.ObjCellID = curPos.ObjCellID; ... } // line 1192
|
||
else change_cell(transitCell); // line 1210 CHANGE only on differ
|
||
```
|
||
|
||
**This is the entire flicker story.** Retail's membership is a *latch*: it is
|
||
advanced through the sweep and committed at the end, never recomputed from the
|
||
static endpoint. acdream re-derives it (see D).
|
||
|
||
## A2. `find_cell_list`: building the candidate array, picking the containing cell, the prune
|
||
|
||
`CObjCell::find_cell_list @ 0x0052b4e0 (pseudo_c:308742)` **[VERIFIED]**. This is
|
||
the canonical 6-argument overload; the 3-arg form
|
||
(`@ 0x0052b960 (pseudo_c:309085)`) just forwards from a `SPHEREPATH`
|
||
(`find_cell_list(&check_pos, num_sphere, global_sphere, cellarray, currcell, path)`).
|
||
|
||
Annotated control flow:
|
||
|
||
```c
|
||
void find_cell_list(Position* pos, uint num_sphere, CSphere* sphere,
|
||
CELLARRAY* arr, CObjCell** out_currCell, SPHEREPATH* path)
|
||
{
|
||
arr->num_cells = 0;
|
||
arr->added_outside = 0;
|
||
objcell_id = pos->objcell_id;
|
||
visibleCell = (objcell_id >= 0x100) ? CEnvCell::GetVisible(objcell_id) // :308754
|
||
: CLandCell::GetVisible(objcell_id); // :308756
|
||
|
||
// (1) SEED the array
|
||
if (objcell_id >= 0x100) { // interior
|
||
if (path) path->hits_interior_cell = 1;
|
||
CELLARRAY::add_cell(arr, objcell_id, visibleCell); // :308766
|
||
} else { // outdoor
|
||
CLandCell::add_all_outside_cells(pos, num_sphere, sphere, arr); // :308769
|
||
}
|
||
|
||
if (visibleCell && num_sphere != 0) {
|
||
// (2) EXPAND through portals: each cell adds its reachable neighbours
|
||
for (i : arr->num_cells)
|
||
arr->cells[i].cell->vtable[+0x80](pos, num_sphere, sphere, arr, path); // find_transit_cells :308782
|
||
|
||
// (3) PICK the single containing cell
|
||
if (out_currCell) {
|
||
*out_currCell = nullptr;
|
||
for (i : arr->num_cells) {
|
||
cell = arr->cells[i].cell;
|
||
blockOffset = LandDefs::get_block_offset(pos->objcell_id, cell->id); // :308802
|
||
localPoint = sphere->center - blockOffset;
|
||
if (cell->vtable[+0x84](&localPoint)) { // point_in_cell :308810
|
||
*out_currCell = cell;
|
||
if (cell->id >= 0x100) { // INTERIOR cell wins
|
||
if (path) path->hits_interior_cell = 1;
|
||
break; // :308819 short-circuit
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// (4) do_not_load_cells PRUNE
|
||
if (arr->do_not_load_cells && (pos->objcell_id & 0xFFFF) >= 0x100) { // :308829
|
||
for (i : arr->num_cells) {
|
||
cell_id = arr->cells[i].cell_id;
|
||
if (cell_id == visibleCell->id) continue; // keep self (+0x28)
|
||
found = false;
|
||
for (s : visibleCell->stab_list) // (+0xe0 num, +0xe4 ptr)
|
||
if (cell_id == s) { found = true; break; }
|
||
if (!found) CELLARRAY::remove_cell(arr, i); // :308863 DROP non-visible cell
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**The four mechanisms, explained:**
|
||
|
||
1. **Seed.** Interior position seeds with its own cell; outdoor seeds with the
|
||
landcell neighbourhood (`add_all_outside_cells`).
|
||
2. **Expand.** `find_transit_cells` (vtable +0x80) adds cells reachable across
|
||
the foot-sphere from this cell's portals — this is how a sweep that
|
||
straddles a portal sees both cells.
|
||
3. **Pick.** The containing cell is the first whose `point_in_cell` returns
|
||
true. **An interior cell that contains the point wins and `break`s the
|
||
loop** (`:308814-308819`) — interior cells take priority over outdoor
|
||
cells. (Without this, a foot sphere overlapping both the doorway landcell
|
||
and the vestibule could pick either.)
|
||
4. **Prune (`do_not_load_cells`).** When set *and* the position is interior,
|
||
remove every candidate that is neither the current cell nor in the current
|
||
cell's `stab_list`. The `stab_list` is the cell's *static* set of "cells I
|
||
can see." Outdoor landcells are essentially never in an interior cell's
|
||
stab list, so when the prune is active, **an interior position cannot have
|
||
the array poisoned by outdoor cells** — which is the membership-stability
|
||
guarantee. The prune is the "do not stream/consider cells my interior
|
||
can't see" rule.
|
||
|
||
**`do_not_load_cells` — when is it set?** It is a per-`CELLARRAY` flag
|
||
(`acclient.h:31577`, `int do_not_load_cells;` in `struct CELLARRAY`). **[VERIFIED]**
|
||
It is set on the *detection / collision* cell arrays used for object-vs-object
|
||
queries and for the per-tick transition cell array where you want a stable
|
||
neighbourhood that doesn't pull in unloaded/invisible cells. ACE exposes it as
|
||
`CellArray.LoadCells` (inverted sense): when `LoadCells == false` the prune
|
||
runs. The prune's effect is the stability it buys (item 4 above). **[INFERRED
|
||
from struct + ACE]** that the per-tick player transition array is built with
|
||
the prune active for interior movers; the decomp's `find_cell_list` only runs
|
||
the prune branch when `do_not_load_cells != 0`, and it is unconditionally
|
||
correct to enable it for an interior position.
|
||
|
||
**ACE cross-check [VERIFIED]** — `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335`
|
||
`find_cell_list` is a near-line-for-line match: seed (`:342-350`), expand
|
||
(`:354-359`), pick with interior short-circuit (`:365-385`, the
|
||
`return; // break?` at `:381`), prune (`:387-412`, gated on `!cellArray.LoadCells`
|
||
and `(position.ObjCellID & 0xFFFF) >= 0x100`, removing cells not in
|
||
`((EnvCell)visibleCell).VisibleCells`).
|
||
|
||
## A3. Precisely how retail avoids cell flicker
|
||
|
||
It is the **combination** of A1 + A2, in this order of importance:
|
||
|
||
1. **Swept-path containment with accept-on-move (A1, dominant).** Membership is
|
||
advanced only when the sweep crosses into a new cell *and* the crossing
|
||
sub-step is accepted; a blocked/standing-still sub-step resets the candidate
|
||
back to the held cell (`validate_transition`, `:272593` reset vs `:272612`
|
||
advance). The end-of-tick commit reads `curr_cell` and only `change_cell`s
|
||
on a real difference (`SetPositionInternal`, `:283414` vs `:283456`). A
|
||
±8 cm push-back is a *block*, which by construction does not move membership.
|
||
|
||
2. **Interior-wins short-circuit in `find_cell_list` (A2 item 3).** When a
|
||
sphere straddles a doorway and both an interior and an outdoor cell contain
|
||
the point, the interior cell is picked and the loop breaks. This prevents
|
||
the "should I be in the vestibule or the landcell?" oscillation at the exact
|
||
threshold.
|
||
|
||
3. **`do_not_load_cells` prune (A2 item 4).** For an interior mover, the
|
||
candidate array can never contain an outdoor cell (it's not in the
|
||
`stab_list`), so the cross/containment machinery operates on a stable
|
||
interior-only neighbourhood.
|
||
|
||
4. **`point_in_cell` semantics (BSP, A4).** Containment is a true BSP
|
||
point-in-solid test in cell-local space, not an AABB — so the boundaries are
|
||
the actual cell geometry, giving crisp, non-overlapping membership at walls.
|
||
|
||
**What guarantees a blocked/standing-still step does NOT change the cell:** the
|
||
`set_check_pos(curr_pos, curr_cell)` reset at `validate_transition:272593` (block
|
||
path) and the `check_pos == curr_pos` no-op path at `:272600-272606` (no move),
|
||
combined with `SetPositionInternal`'s `if (this->cell == curr_cell)` guard at
|
||
`:283414`. **[VERIFIED]**
|
||
|
||
## A4. Transitions: indoor→outdoor, outdoor→indoor, interior→interior; `CCellPortal` vs `CBldPortal`
|
||
|
||
Two distinct portal types exist:
|
||
|
||
- **`CCellPortal`** (`acclient.h:32300`) **[VERIFIED]** — connects two **interior
|
||
EnvCells** (room↔room). Fields: `other_cell_id`, `other_cell_ptr` (cached
|
||
neighbour), `portal` (the polygon), `portal_side`, `other_portal_id`,
|
||
`exact_match`. An `other_cell_id == 0xffffffff` means the portal opens to the
|
||
**outside** (an exit portal).
|
||
- **`CBldPortal`** (`acclient.h:32094`) **[VERIFIED]** — connects the **outdoor
|
||
landblock to a building's interior** (the door from the street). Fields:
|
||
`portal_side`, `other_cell_id`, `other_portal_id`, `exact_match`,
|
||
`num_stabs`, `stab_list`, `sidedness`. Held by `CBuildingObj.portals`
|
||
(`acclient.h:31908`).
|
||
|
||
### Interior→interior (room↔room)
|
||
|
||
`CEnvCell::find_transit_cells (sphere variant) @ 0x0052c820 (pseudo_c:309968)`
|
||
**[VERIFIED]**: for each `CCellPortal`, get the neighbour via
|
||
`CCellPortal::GetOtherCell`, transform the sphere to that cell's local space,
|
||
and if `CCellStruct::sphere_intersects_cell != OUTSIDE`, `add_cell` the
|
||
neighbour (`:310054`). The `portal_side` field gates direction. When a portal's
|
||
`other_cell_id == 0xffffffff` (exterior portal) the function sets `var_44`,
|
||
and after the loop:
|
||
```c
|
||
if (var_44 != 0) CLandCell::add_all_outside_cells(arg2, arg3, arg4, arg5); // :310119-310120
|
||
```
|
||
So an interior cell with an exit portal also pulls in the outdoor landcell
|
||
neighbourhood — making the *outdoor* landcell a candidate when you're near the
|
||
door from inside.
|
||
|
||
### Outdoor→indoor (street→building)
|
||
|
||
The landcell carries a building via `CSortCell` (the `CLandCell : CSortCell`
|
||
inheritance). `CLandCell::find_transit_cells @ 0x00533800 (pseudo_c:317603)`
|
||
**[VERIFIED]**:
|
||
```c
|
||
CLandCell::add_all_outside_cells(...); // neighbour landcells
|
||
CSortCell::find_transit_cells(this, ...); // the building on this landcell
|
||
```
|
||
`CSortCell::find_transit_cells @ 0x00534060 (pseudo_c:318309)` **[VERIFIED]**
|
||
→ `CBuildingObj::find_building_transit_cells @ 0x006b5230 (pseudo_c:701214)`
|
||
**[VERIFIED]**:
|
||
```c
|
||
for (i : building->num_portals) {
|
||
CBldPortal* p = building->portals[i];
|
||
CEnvCell* interior = CBldPortal::GetOtherCell(p);
|
||
if (interior) CEnvCell::check_building_transit(interior, p->other_portal_id, ...); // :701227
|
||
}
|
||
```
|
||
`check_building_transit` adds the interior EnvCell to the array if the sphere
|
||
has crossed the building portal plane. So when you walk a foot-sphere up to a
|
||
door from the street, the interior vestibule becomes a candidate, `point_in_cell`
|
||
picks it (interior wins), and `validate_transition` advances `curr_cell` into
|
||
the building.
|
||
|
||
### Indoor→outdoor (exit)
|
||
|
||
As shown in A1 Step 4: when no interior cell contains the sphere center,
|
||
`check_other_cells` calls `LandDefs::adjust_to_outside` to convert the interior
|
||
cell id to the surrounding landcell id (`pseudo_c:272783`), then re-resolves.
|
||
`Position::get_outside_cell_id @ 0x004527b0 (pseudo_c:91552)` **[VERIFIED]** is the
|
||
helper that computes the outdoor landcell id from an interior position's frame
|
||
(used by render too).
|
||
|
||
### How the exit portal / outdoor landcell gets added and chosen
|
||
|
||
- *Added*: via `add_all_outside_cells` (triggered by `var_44` in
|
||
`find_transit_cells` when an exit portal is present, or directly in the
|
||
outdoor seed branch).
|
||
- *Chosen*: by `point_in_cell` in the `find_cell_list` pick loop — but the
|
||
outdoor cell is only chosen once the sphere center is no longer inside any
|
||
interior cell (interior wins, A2 item 3). So there is a clean handoff: you
|
||
remain in the interior until the center leaves the interior solid, then the
|
||
landcell wins.
|
||
|
||
## A5. Two mechanisms or one? — the cell ARRAY vs `curr_cell`
|
||
|
||
**They are two distinct things that interact within one transition.** **[VERIFIED]**
|
||
|
||
- **`CELLARRAY cell_array`** (`acclient.h:31574`) — a *set* of cells the
|
||
collision sweep must test against this tick. Built by `find_cell_list` /
|
||
`find_transit_cells`. Used by `insert_into_cell` (the primary cell) and
|
||
`check_other_cells` (every other cell in the array). Its purpose is
|
||
*collision coverage*: you must collide against walls/floors of every cell
|
||
your sphere overlaps, not just one.
|
||
|
||
- **`sphere_path.curr_cell` / `check_cell`** — the *single* membership cell.
|
||
`curr_cell` = accepted; `check_cell` = candidate this sub-step.
|
||
|
||
**How they relate in one transition:** Each sub-step, `check_other_cells` calls
|
||
`find_cell_list(&cell_array, &out_containing, &sphere_path)`. This:
|
||
(a) rebuilds the `cell_array` (collision set) for the candidate position, and
|
||
(b) returns the single containing cell in `out_containing`, which becomes the
|
||
new `check_cell` (`check_other_cells:272760-272765`). Then `validate_transition`
|
||
promotes `check_cell → curr_cell` on accept. So the **array drives collision**;
|
||
the **containing-cell pick drives membership**; both come out of the same
|
||
`find_cell_list` call but are different outputs.
|
||
|
||
This distinction matters for acdream: acdream already iterates a candidate set
|
||
for collision (Phase A4 `CheckOtherCells`, `TransitionTypes.cs:2055-2067`), and
|
||
already retargets `CheckCellId` to the containing cell mid-sweep
|
||
(`TransitionTypes.cs:2074-2075`). The membership latch is therefore *already
|
||
tracked* in `sp.CurCellId`. (See D.)
|
||
|
||
---
|
||
|
||
# B. Underground / dungeons
|
||
|
||
## B6. How dungeons are represented (dat + runtime), vs building interiors
|
||
|
||
**There is no separate dungeon data type.** Both dungeons and building
|
||
interiors are made of the same `CEnvCell` graph (`acclient.h:32072`). The
|
||
difference is purely in the *landblock* they sit on:
|
||
|
||
- A **landblock** (`CLandBlockInfo`, `acclient.h:31893`) **[VERIFIED]** carries
|
||
`num_cells` + `cell_ids` + `cells (CEnvCell**)` — the interior cells — *and*
|
||
separately `num_buildings` + `buildings (BuildInfo**)`.
|
||
- A **dungeon** landblock has terrain heights all zero, ≥1 EnvCell, and **no
|
||
buildings**.
|
||
- A **building interior** (cottage/inn) sits on a landblock with **real
|
||
terrain** and the interior is reached through a `CBldPortal` from the
|
||
outdoors; the EnvCells frequently have `seen_outside == 1`.
|
||
|
||
**ACE's authoritative definition [VERIFIED]** —
|
||
`references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575` `IsDungeon`:
|
||
```csharp
|
||
// a dungeon landblock is determined by:
|
||
// - all heights being 0
|
||
// - having at least 1 EnvCell (0x100+)
|
||
// - contains no buildings
|
||
foreach (var height in Height) if (height != 0) { isDungeon = false; return; }
|
||
isDungeon = Info != null && Info.NumCells > 0 && Info.Buildings != null && Info.Buildings.Count == 0;
|
||
```
|
||
And `HasDungeon` (`:621`) is the same minus the height check — for landblocks
|
||
with both an overworld and a basement (e.g. mansions).
|
||
|
||
> **Important caveat:** `IsDungeon`/`HasDungeon` are *ACE server* heuristics for
|
||
> spawn/teleport logic. The **retail client** does not compute or need them. The
|
||
> client just renders whatever cell the viewer is in (C12). I cite them only to
|
||
> characterize the dat layout difference, not as a client code path. **[VERIFIED
|
||
> they are ACE-only; INFERRED the client has no equivalent — no such function
|
||
> found in the decomp.]**
|
||
|
||
**Cell id convention** (the runtime discriminator) **[VERIFIED via decomp use]**:
|
||
- Low 16 bits `0x0001..0x0040` (1–64) → an **outdoor landcell** of a landblock
|
||
(8×8 grid). `find_cell_list` treats `(id & 0xFFFF) < 0x100` as outdoor
|
||
(`pseudo_c:308761`, `308753`).
|
||
- Low 16 bits `>= 0x0100` → an **EnvCell** (interior, building or dungeon).
|
||
- High 16 bits = the landblock id. So `0xA9B40031` = landblock `0xA9B4`,
|
||
outdoor cell `0x31`; `0xA9B40170` = landblock `0xA9B4`, EnvCell `0x170`.
|
||
|
||
## B7. Moving through a dungeon: cell tracking, loading, no sky/terrain
|
||
|
||
- **Cell tracking** is identical to building interiors — the same
|
||
`transition`/`validate_transition`/`change_cell` machinery, just that *every*
|
||
cell you cross is an EnvCell (`>= 0x100`) connected by `CCellPortal`s.
|
||
- **Loading/streaming.** When the player's cell changes,
|
||
`CEnvCell::grab_visible_cells @ 0x0052e220 (pseudo_c:311878)` **[VERIFIED]** is
|
||
called (via `CellManager::ChangePosition`, see C13). It adds the current cell
|
||
+ every cell in its `stab_list` to the visible-cell table:
|
||
```c
|
||
CEnvCell::add_visible_cell(this->id);
|
||
for (i : num_stabs) CEnvCell::add_visible_cell(stab_list[i]);
|
||
if (this->seen_outside == 0) return; // :311893 — dungeon: STOP, no landscape
|
||
return LScape::grab_visible_cells(landscape); // building: also load the terrain
|
||
```
|
||
This is the precise place the engine decides "load the outdoor world too" or
|
||
not: **`seen_outside == 0` → don't touch the landscape** (dungeon). A
|
||
building interior with `seen_outside == 1` loads the surrounding terrain.
|
||
- **No sky/terrain to draw** is decided by `seen_outside` at render time
|
||
(C10/C12): if the viewer cell and all visible cells have `seen_outside == 0`,
|
||
no exit portal is ever encountered, so `PView::DrawCells`'
|
||
`outside_view.view_count` stays 0 and `LScape::draw` is never called
|
||
(`pseudo_c:432715`). **[VERIFIED]**
|
||
|
||
## B8. Is there an explicit "underground" flag?
|
||
|
||
**No explicit "underground" flag exists in the retail client.** **[VERIFIED — by
|
||
absence]**. "Underground" is *emergent*:
|
||
|
||
> You are underground iff your current cell is an `EnvCell` (`id & 0xFFFF >=
|
||
> 0x100`) whose `seen_outside == 0` and which has no exit-portal path to the
|
||
> outdoors.
|
||
|
||
The closest thing to a flag is the per-cell **`seen_outside`** boolean
|
||
(`CObjCell.seen_outside`, `acclient.h:30929`; dat flag
|
||
`EnvCellFlags.SeenOutside = 0x1`, `references/ACViewer/.../EnvCellFlags.cs:7`).
|
||
**[VERIFIED]**. It is consumed at:
|
||
- `CEnvCell::grab_visible_cells:311893` (load landscape or not),
|
||
- `SmartBox::RenderNormalMode:92649` and `CellManager::ChangePosition:94575,94649,94682`
|
||
(draw landscape / keep landscape loaded).
|
||
|
||
There is **no** `is_underground` field on `Position`, on the landblock, or on
|
||
the cell. The distinction "dungeon vs building interior vs outdoor" is fully
|
||
captured by (cell id range) × (`seen_outside`) × (presence of exit portals).
|
||
|
||
---
|
||
|
||
# C. Rendering inside and outside — the seamless seal
|
||
|
||
## C9. The single-pass visible-set build (`PView`)
|
||
|
||
The render visibility is a **breadth-first portal traversal** rooted at the
|
||
viewer's cell, producing an ordered cell-draw list plus per-portal screen
|
||
clip regions. The data structures (`acclient.h:32346`) **[VERIFIED]**:
|
||
```c
|
||
struct portal_view_type { // one per EnvCell (CEnvCell.portal_view)
|
||
DArray<portal_info> portal; // per-portal seen/inflag state
|
||
view_type view; // poly + vertex screen-clip geometry
|
||
float max_indist;
|
||
unsigned int view_count;
|
||
int cell_view_done;
|
||
int view_timestamp;
|
||
int update_count;
|
||
};
|
||
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
|
||
```
|
||
|
||
### `PView::ConstructView(CEnvCell* root, ushort) @ 0x005a57b0 (pseudo_c:433750)` **[VERIFIED]**
|
||
|
||
```c
|
||
this->outside_view.view_count = 0;
|
||
PView::master_timestamp += 1;
|
||
this->cell_todo_num = 0; this->cell_draw_num = 0;
|
||
PView::InitCell(this, root, arg3); // compute root's active portals + clip
|
||
PView::InsCellTodoList(this, root, 0); // push root onto BFS todo
|
||
while (true) {
|
||
if (cell_todo_num <= 0) return;
|
||
cell = cell_todo_list[--cell_todo_num].cell;
|
||
if (cell == 0) return;
|
||
cell_draw_list[cell_draw_num++] = cell; // ADD to visible draw list
|
||
cell->portal_view[...]->cell_view_done = 1;
|
||
if (PView::ClipPortals(this, cell, 0)) // clip this cell's portals to current view
|
||
PView::AddViewToPortals(this, cell); // enqueue neighbours through visible portals
|
||
}
|
||
```
|
||
|
||
### `PView::InitCell @ 0x005a4b70 (pseudo_c:432896)` **[VERIFIED]**
|
||
|
||
For the cell, walks `num_portals`, computes each portal polygon's plane vs the
|
||
viewer (`Render::FrameCurrent->viewer.viewpoint`), and sets per-portal
|
||
`inflag`/`seen` based on `portal_side` (the side the viewer is on). Computes
|
||
`max_indist` (`esi[0xd]`). This decides which of the cell's portals can be seen
|
||
through from the current viewpoint.
|
||
|
||
### `PView::AddViewToPortals @ 0x005a52d0 (pseudo_c:433446)` **[VERIFIED]**
|
||
|
||
For each *active* portal of the cell:
|
||
```c
|
||
esi_2 = portal.other_cell_ptr; // neighbour EnvCell
|
||
if (esi_2 && portal_active) {
|
||
if (neighbour not yet inited) {
|
||
InitCell(this, esi_2, portal.other_portal_id);
|
||
InsCellTodoList(this, esi_2, max_indist); // enqueue neighbour
|
||
if (portal.flag >= 0) SetOtherSeen(this, cell, portal_idx); // record exit-portal seen
|
||
} else AddToCell(...); // merge additional view
|
||
}
|
||
```
|
||
This is the recursive portal expansion: each visible portal pulls its
|
||
neighbour cell into the todo list, clipped to the portal's screen region.
|
||
|
||
### Output
|
||
|
||
- **`cell_draw_list[0..cell_draw_num]`** — the ordered set of visible EnvCells.
|
||
- Per-cell **`portal_view[...]->view`** — the accumulated screen-space clip
|
||
region(s) (poly+vertex) through which each cell is visible (so a far cell
|
||
seen through two doorways is clipped to the intersection).
|
||
- **`outside_view`** — accumulated clip region for the outdoors seen through
|
||
exit portals (drives `LScape::draw`, C10).
|
||
|
||
**Cross-check:** WorldBuilder and ACViewer do **NOT** implement this. ACViewer
|
||
brute-force draws all loaded EnvCells with a `DungeonMode` cull-mode toggle
|
||
(`references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564`) **[VERIFIED]** —
|
||
no portal-recursive clip. WorldBuilder uses a flat stencil "inside-out" pass
|
||
(per project memory `project_indoor_portal_visibility.md`). So **`PView` is the
|
||
unique authority** for the seamless seal; the references diverge here and the
|
||
decomp wins.
|
||
|
||
## C10. Drawing the OUTSIDE through a doorway (no blue hole)
|
||
|
||
Two cooperating mechanisms.
|
||
|
||
### (a) Exit portals carry the landscape into the indoor traversal
|
||
|
||
In `PView::ClipPortals @ 0x005a5520 (pseudo_c:433572)` **[VERIFIED]**, when a
|
||
portal's `other_cell_id == 0xffffffff` (an exterior portal):
|
||
```c
|
||
if (*(uint32_t*)esi_3 == 0xffffffff) { // :433662 exit portal
|
||
if (this->draw_landscape) { // :433664
|
||
if (cliplandscape) Render::copy_view(this, &clip_view, ecx_8); // :433674 — landscape clipped to portal
|
||
else if (draw_landscape) Render::copy_view(this, nullptr, 0);
|
||
}
|
||
}
|
||
```
|
||
So the doorway's screen region (`clip_view`) is registered as a region through
|
||
which the **landscape** is drawn. The `this->draw_landscape` flag is set when
|
||
the indoor PView is allowed to show outdoors.
|
||
|
||
### (b) `DrawCells` draws the landscape first when any portal opened outside
|
||
|
||
`PView::DrawCells @ 0x005a4840 (pseudo_c:432709)` **[VERIFIED]**:
|
||
```c
|
||
if (this->outside_view.view_count > 0) { // :432715 some portal saw outside
|
||
Render::useSunlightSet(1);
|
||
Render::PortalList = this;
|
||
LScape::draw(this->lscape); // :432719 DRAW THE OUTDOOR WORLD (terrain+sky+buildings)
|
||
D3DPolyRender::FlushAlphaList(0);
|
||
...
|
||
// CONDITIONAL clear — only clears Z where portals were drawn:
|
||
if (forceClear || D3DPolyRender::portalsDrawnCount != 0)
|
||
RenderDevice->Clear(4, 0x820fc0, 1.0); // :432731-432732 (4 == Z-only)
|
||
// then draw each visible EnvCell's drawing_bsp + portal polys:
|
||
for (cell : cell_draw_list) {
|
||
if (cell->structure->drawing_bsp) {
|
||
... setup_view per portal_view ...
|
||
for (portal : cell->portals)
|
||
if (portal.other_cell_id == 0xffffffff)
|
||
D3DPolyRender::DrawPortalPolyInternal(portal.portal, 0); // :432785-432786 stencil the opening
|
||
}
|
||
}
|
||
for (cell : cell_draw_list) RenderDevice->DrawEnvCell(cell); // :432853 draw interior geometry
|
||
}
|
||
```
|
||
|
||
**Why there is no blue clear-color hole:**
|
||
- The landscape (terrain + sky + exterior buildings) is drawn *first*, **inside
|
||
the doorway clip region**, when `outside_view.view_count > 0`.
|
||
- The `Clear` is `Clear(4, …)` — flag `4` is the **Z-buffer**, not the full
|
||
color buffer; and it is *conditional* on portals having been drawn. There is
|
||
no unconditional `Clear(color)` painting the frame blue.
|
||
- The exit-portal polygons are drawn as stencil masks (`DrawPortalPolyInternal`)
|
||
so the outdoors only shows through the actual door opening.
|
||
|
||
So from inside a cottage, looking at the door, you see the real terrain/sky/
|
||
rain through the opening — clipped to the door — and the walls around it. The
|
||
"blue hole" in acdream is precisely the *absence* of step (b): acdream's indoor
|
||
pass clears to clear-color and never injects the landscape into the doorway
|
||
clip.
|
||
|
||
`PView::DrawInside @ 0x005a5860 (pseudo_c:433793)` **[VERIFIED]** is the top-level
|
||
indoor entry that ties it together:
|
||
```c
|
||
CEnvCell::curr_view_push(viewer_cell);
|
||
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // seed PVS from stab list
|
||
Render::copy_view(viewer_cell->portal_view[...], nullptr, 4);
|
||
edx = PView::ConstructView(this, viewer_cell, 0xffff); // build visible set
|
||
PView::DrawCells(this, edx); // draw (incl. landscape thru doors)
|
||
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
|
||
```
|
||
|
||
`PView::ConstructView(CBldPortal*, CPolygon*, …) @ 0x005a59a0 (pseudo_c:433827)`
|
||
**[VERIFIED]** is the *building-portal* sibling (used by `DrawPortal`/outdoor
|
||
pview): it tests viewer-vs-portal-plane sidedness, gets the interior via
|
||
`CEnvCell::GetVisible(other_cell_id)`, clips, and recurses
|
||
`ConstructView(interior, other_portal_id)` (`:433879`). This is how the
|
||
**outdoor** render draws *into* a building when the camera can see the door
|
||
from the street — the mirror image of (a)/(b).
|
||
|
||
## C11. Sealing interiors: capped ceilings, no bleed-through, entity/particle clipping
|
||
|
||
- **Ceilings/walls are sealed by construction.** An EnvCell's geometry
|
||
(`CCellStruct.polygons` + `drawing_bsp`, `acclient.h:32285`) is a *closed*
|
||
cell — the dat authoring includes the ceiling/floor/walls as polygons. There
|
||
is no "cap the ceiling" step; the cell mesh is already a sealed box with
|
||
portal holes only where `CCellPortal`s exist. `DrawCells` draws
|
||
`cell->structure->drawing_bsp` for each visible cell (`pseudo_c:432745`,
|
||
`432853`). **[VERIFIED]**
|
||
- **No outdoor bleed-in.** Because the visible set is *only* the cells reached
|
||
by the portal BFS (`cell_draw_list`), and the landscape is drawn *only*
|
||
through exit-portal clip regions, the outdoor world cannot paint over
|
||
interior walls. If `seen_outside == 0` everywhere reachable, the landscape is
|
||
never drawn at all.
|
||
- **Entity/particle clipping to visible cells.** Objects live in a cell's
|
||
`shadow_object_list` / `object_list` (`CObjCell.object_list`,
|
||
`acclient.h:30920`). `DrawCells` draws per-cell objects via
|
||
`DrawObjCellForDummies(cell)` (`pseudo_c:432878`) walking only the
|
||
`cell_draw_list`. An object in a non-visible cell is simply not iterated.
|
||
`Render::PortalList` is set per cell so object draws inherit the same clip
|
||
region (`:432877`). **[VERIFIED]** Particles attached to objects follow the
|
||
object's cell membership.
|
||
|
||
## C12. Terrain + sky: drawn or not, as a function of the current cell
|
||
|
||
The decision is made once per frame in
|
||
`SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635)` **[VERIFIED]**:
|
||
```c
|
||
// edi_2 ≈ "viewer is in an outdoor landcell" (decompiler-garbled, but the branch is binary)
|
||
if (edi_2 != 0 || this_1->viewer_cell->seen_outside != 0) ebx_1 = 1; // :92649 outside-relevant
|
||
else ebx_1 = 0;
|
||
|
||
if (edi_2 == 0) { // VIEWER IS INSIDE AN ENVCELL
|
||
if (ebx_1 != 0) { // cell seen_outside: update landscape viewpoint too
|
||
eax_1 = Position::get_outside_cell_id(&this_1->viewer);
|
||
LScape::update_viewpoint(this_1->lscape, eax_1);
|
||
}
|
||
Render::update_viewpoint(&this_1->viewer);
|
||
RenderDevice->DrawInside(viewer_cell); // :92675 indoor PView path (draws landscape thru doors if seen_outside)
|
||
} else { // VIEWER IS OUTSIDE (landcell)
|
||
LScape::update_viewpoint(this_1->lscape, this_1->viewer.objcell_id);
|
||
Render::update_viewpoint(&this_1->viewer);
|
||
Render::set_default_view();
|
||
Render::useSunlightSet(1);
|
||
LScape::draw(this_1->lscape); // :92683 normal outdoor render
|
||
}
|
||
```
|
||
|
||
So:
|
||
- **Viewer in an outdoor landcell** (`id & 0xFFFF < 0x100`) → `LScape::draw`
|
||
(full terrain + sky + buildings; buildings draw *their* interiors via
|
||
building-portal `ConstructView` when the camera sees the door).
|
||
- **Viewer in an EnvCell** → `DrawInside` (portal PVS). Terrain/sky appear
|
||
**only** through exit portals, and only if some reachable cell has
|
||
`seen_outside`. A dungeon (`seen_outside == 0` everywhere) shows no
|
||
terrain/sky.
|
||
|
||
This is a single binary on the current cell's id range + `seen_outside`. No
|
||
separate "is it raining indoors?" logic — rain/sky come through the doorway
|
||
because the *landscape* is drawn through the exit-portal clip.
|
||
|
||
## C13. Is render's cell the SAME `curr_cell`/graph as physics?
|
||
|
||
**Yes — render reads the same `objcell_id` and traverses the same cell graph.**
|
||
**[VERIFIED]**
|
||
|
||
`CellManager::ChangePosition @ 0x004559b0 (pseudo_c:94601)` **[VERIFIED]** is the
|
||
render-side cell tracker, driven by the *position's* `objcell_id` (the same id
|
||
physics maintains):
|
||
```c
|
||
uint objcell_id = arg2->objcell_id; // the Position's cell id
|
||
if (objcell_id == 0) { CellManager::Reset(this); return; }
|
||
...
|
||
if (load_pos.objcell_id != objcell_id || curr_cell == 0) {
|
||
CellManager::PreFetchCells(this, objcell_id, edi);
|
||
...
|
||
CObjCell* eax_2 = CObjCell::Get(arg2->objcell_id); // :94640 RESOLVE from the same id
|
||
if (eax_2) {
|
||
if (ebp_2) { eax_2->grab_visible_cells(); curr_cell = eax_2; }
|
||
else if (eax_2->seen_outside || keep_lscape_loaded) { // :94649 keep landscape if seen_outside
|
||
LScape::update_loadpoint(lscape, Position::get_outside_cell_id(arg2));
|
||
eax_2->grab_visible_cells(); curr_cell = eax_2;
|
||
} else { LScape::release_all(lscape); eax_2->grab_visible_cells(); curr_cell = eax_2; }
|
||
}
|
||
...
|
||
Render::player_pos.objcell_id = curr_cell->pos.objcell_id; // :94671
|
||
}
|
||
```
|
||
|
||
The render `CellManager.curr_cell` is `CObjCell::Get(position.objcell_id)` — the
|
||
same cell graph (`CObjCell::GetVisible`/`CEnvCell::visible_cell_table`) the
|
||
physics uses (`find_cell_list` resolves `GetVisible(objcell_id)` too,
|
||
`pseudo_c:308754/308756`). The viewer's cell (`SmartBox.viewer_cell`) is the
|
||
camera's cell; in 1st person it is the player's cell; in 3rd person the camera
|
||
may be in a neighbour cell, but it is resolved through the *same* graph.
|
||
|
||
`CObjCell::GetVisible @ 0x0052ad40 (pseudo_c:308209)` **[VERIFIED]** is the shared
|
||
resolver:
|
||
```c
|
||
return (arg1 >= 0x100) ? CEnvCell::GetVisible(arg1) : CLandCell::GetVisible(arg1);
|
||
```
|
||
`CEnvCell::GetVisible @ 0x0052dc10 (pseudo_c:311378)` is a hashtable lookup in
|
||
`CEnvCell::visible_cell_table`; `CLandCell::GetVisible @ 0x00532db0
|
||
(pseudo_c:316986)` → `LScape::get_landcell`. **[VERIFIED]**
|
||
|
||
**Conclusion (central to acdream's decision):** retail has **one** cell graph
|
||
and **one** `objcell_id`. Physics maintains `curr_cell`; render reads
|
||
`position.objcell_id` and resolves through the same `GetVisible`. There is no
|
||
separate render-side cell discovery from the camera XYZ — the render cell *is*
|
||
the physics cell (for the player/viewer). The PVS traversal (`PView`) then
|
||
expands from that single root through the shared `CEnvCell::portals`.
|
||
|
||
---
|
||
|
||
# D. Synthesis for acdream
|
||
|
||
## D-pre. What acdream does today (the gap, precisely)
|
||
|
||
Read in source **[VERIFIED]**:
|
||
|
||
1. **The sweep already tracks the cell correctly.** acdream's
|
||
`SpherePath` has `CurCellId` + `CheckCellId`
|
||
(`src/AcDream.Core/Physics/TransitionTypes.cs:335-336`), and its
|
||
`ValidateTransition` advances/resets them exactly like retail:
|
||
```csharp
|
||
// TransitionTypes.cs:3404-3434
|
||
if (transitionState == OK && sp.CheckPos != sp.CurPos) { sp.CurPos = sp.CheckPos; sp.CurCellId = sp.CheckCellId; ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); }
|
||
else if (transitionState == OK) { sp.SetCheckPos(sp.CurPos, sp.CurCellId); }
|
||
else if (transitionState != Invalid) { ...; sp.SetCheckPos(sp.CurPos, sp.CurCellId); transitionState = OK; }
|
||
```
|
||
It also retargets `CheckCellId` to the containing cell mid-sweep
|
||
(`TransitionTypes.cs:2061-2075`, the Phase A4 `FindCellSet` +
|
||
`SetCheckPos(sp.CheckPos, containingCellId)`).
|
||
|
||
2. **The engine THROWS AWAY the tracked cell and re-derives statically.** This
|
||
is the bug. `PhysicsEngine.ResolveWithTransition` builds the result:
|
||
```csharp
|
||
// PhysicsEngine.cs:907-912 (OK path)
|
||
resolveResult = new ResolveResult(
|
||
sp.CheckPos,
|
||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), // :909 RE-DERIVE, sp.CheckCellId only a fallback
|
||
onGround, collisionNormalValid, collisionNormal);
|
||
// PhysicsEngine.cs:926-931 (partial/blocked path)
|
||
resolveResult = new ResolveResult(
|
||
sp.CheckPos,
|
||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), // :928 RE-DERIVE
|
||
...);
|
||
```
|
||
`ResolveCellId @ PhysicsEngine.cs:294` then re-runs `CellTransit.FindCellList`
|
||
(indoor) or a terrain-grid lookup (outdoor) **from the static endpoint**,
|
||
and even re-verifies with a sphere-overlap test + a "doorway hold margin"
|
||
band-aid (`PhysicsEngine.cs:347-363, 402-421`). This is the static
|
||
re-derive the prompt identified: jitter at the boundary flips the answer.
|
||
|
||
3. **No `do_not_load_cells` prune.** `CellTransit.BuildCellSetAndPickContaining`
|
||
(`CellTransit.cs:426`) builds the candidate set and picks the *first*
|
||
candidate whose `PointInsideCellBsp` is true (`:524-534`) — with **no
|
||
interior-wins short-circuit** and **no stab-list prune**. HashSet iteration
|
||
order makes the pick non-deterministic when multiple cells contain the
|
||
center.
|
||
|
||
4. **Render maintains a SEPARATE cell system.** `CellVisibility.FindCameraCell`
|
||
(`src/AcDream.App/Rendering/CellVisibility.cs:389`) independently re-derives
|
||
the camera cell from the eye position, *with a 3-frame grace-frame hack*
|
||
(`CellSwitchGraceFrameCount = 3`, `:214`), ported from ACME's flat-stencil
|
||
`EnvCellManager` — not retail's PView. `GetVisibleCells` (`:521`) is a
|
||
separate BFS. This is the root of the "render strobes / blue hole / bleed"
|
||
family.
|
||
|
||
5. **The unified seam already exists.** W1 shipped `CellGraph`
|
||
(`src/AcDream.Core/World/Cells/CellGraph.cs`) with `GetVisible(id)` (the
|
||
retail resolver) and `CurrCell` (the single membership answer), plus faithful
|
||
`EnvCell` (`StabList`, `Portals`, `SeenOutside` from `EnvCellFlags.SeenOutside`,
|
||
`ContainmentBsp`, `PointInCell`). `CurrCell` is currently written by
|
||
`SetCurrAndReturn` (`PhysicsEngine.cs:287`) but from the *re-derived* id, and
|
||
it has **no render reader yet**.
|
||
|
||
## D14. The retail-faithful target architecture
|
||
|
||
**Adopt retail's "track membership through the sweep; one cell graph for
|
||
physics + render; one portal-visibility traversal" model.** Concretely:
|
||
|
||
1. **Membership is a latch tracked through the sweep, committed at the end.**
|
||
`ResolveWithTransition` must return `sp.CurCellId` (the value advanced by
|
||
`ValidateTransition` / retargeted by the mid-sweep containing-cell pick),
|
||
**not** a re-derive from the final XYZ. The static `ResolveCellId` is
|
||
*deleted* from the per-tick output path. This mirrors
|
||
`CPhysicsObj::SetPositionInternal(CTransition*)` reading
|
||
`sphere_path.curr_cell` (`pseudo_c:283403`) and `change_cell`-on-differ
|
||
(`:283414/283456`).
|
||
|
||
2. **`PlayerMovementController.CellId` becomes "set from `sp.CurCellId`,
|
||
change only on differ."** Add an explicit "did the cell change?" event so the
|
||
render and streaming can react (the analog of `change_cell` /
|
||
`CellManager::ChangePosition`). Write `CellGraph.CurrCell = GetVisible(newId)`
|
||
here — once, authoritatively.
|
||
|
||
3. **`CellTransit` gets the interior-wins short-circuit + the
|
||
`do_not_load_cells` prune.** In `BuildCellSetAndPickContaining`: when a
|
||
candidate is interior and contains the center, pick it and break
|
||
(`find_cell_list:308814-308819`); and when the seed is interior, prune every
|
||
candidate not equal to the seed cell and not in the seed cell's `StabList`
|
||
(`find_cell_list:308829-308867`). This restores membership stability and
|
||
makes the pick deterministic — and lets us delete the `DoorwayHoldMargin`
|
||
band-aid (`PhysicsEngine.cs:416-421`) and the sphere-overlap re-verify
|
||
(`:347-363`), since the latch + prune make them unnecessary.
|
||
|
||
4. **Render obeys the physics `CurrCell` and runs ONE portal-visibility
|
||
traversal.** Replace `CellVisibility.FindCameraCell`'s independent
|
||
re-derive + grace-frames with: *root the PVS at the player's `CellGraph.CurrCell`*
|
||
(camera/eye still drives projection, but the visibility ROOT is the physics
|
||
cell — this is exactly the flap fix already discovered, CLAUDE.md U.4c). For
|
||
the camera in 3rd person, resolve the camera's cell through the *same*
|
||
`CellGraph.GetVisible` if needed, but prefer the player cell as root (retail's
|
||
`viewer_cell` is the camera cell, but acdream's chase camera is the source of
|
||
the stale-cell flap — keep root = player cell, per the shipped U.4c
|
||
decision). Then port the `PView::ConstructView` BFS to produce one visible
|
||
cell list + exit-portal clip regions, and one decision (`SmartBox::RenderNormalMode`):
|
||
outdoor landcell → terrain pipeline; EnvCell → indoor pipeline that draws the
|
||
landscape through exit portals when `seen_outside`.
|
||
|
||
5. **Terrain/sky gating keys off the current cell's id range + `seen_outside`,
|
||
exactly once.** `(CurrCell.Id & 0xFFFF) < 0x100` → draw terrain/sky.
|
||
`EnvCell && seen_outside` → draw terrain/sky **only through exit-portal clip
|
||
regions**. `EnvCell && !seen_outside` (dungeon) → never draw terrain/sky.
|
||
This replaces the abandoned two-pipe (`cameraInsideBuilding`) split.
|
||
|
||
## D15. Should membership be advanced in the sweep, and should render obey it?
|
||
|
||
**Yes to both. Justified directly from the decomp:**
|
||
|
||
- **Advance membership inside the sweep (port `validate_transition`'s
|
||
`curr_cell` advance + the prune + drop the static re-derive):**
|
||
`validate_transition` is the *only* place retail mutates `curr_cell`, and it
|
||
does so as a function of whether the swept sub-step was accepted
|
||
(`pseudo_c:272612` advance vs `:272593` reset). `SetPositionInternal` reads
|
||
exactly that (`pseudo_c:283403`) and commits via `change_cell`-on-differ.
|
||
There is **no static endpoint re-derivation anywhere** in retail's per-tick
|
||
path — `find_cell_list` is called *during* the sweep (in `check_other_cells`)
|
||
and *at the end with `arg5 == nullptr`* (so it does not pick membership). The
|
||
`do_not_load_cells` prune is part of that same `find_cell_list`
|
||
(`pseudo_c:308829`). acdream already has the latch infrastructure
|
||
(`sp.CurCellId` + `ValidateTransition`); the fix is to *use it* at the engine
|
||
output and add the prune.
|
||
|
||
- **Render obeys the physics `curr_cell` + a single portal traversal:**
|
||
`CellManager::ChangePosition` resolves render's `curr_cell` from
|
||
`position.objcell_id` via `CObjCell::Get` — the *same* graph and id physics
|
||
maintains (`pseudo_c:94640`). `SmartBox::RenderNormalMode` keys the single
|
||
inside/outside decision off `viewer_cell` (`pseudo_c:92649,92665`). `PView`
|
||
expands from that one root. acdream's separate `CellVisibility.FindCameraCell`
|
||
with grace-frames is non-retail and is the documented source of the
|
||
flap/strobe; rooting the PVS at the shared `CurrCell` (W2/W3) is the
|
||
retail-faithful replacement. **[VERIFIED across decomp + ACE + acdream code]**
|
||
|
||
The risk the prompt flags ("touching the collision sweep where acdream has a
|
||
history of bugs") is **largely avoided** by this plan: the sweep itself is
|
||
*not* changed (its `CurCellId` tracking is already correct and tested). We
|
||
change (a) the engine *output* (return `sp.CurCellId`), (b) add a prune in
|
||
`CellTransit` *pick*, and (c) the *render* root. None of these alter
|
||
`insert_into_cell` / `FindEnvCollisions` / step-up / AdjustOffset — the parts
|
||
that have historically broken (issue #98 etc.). This is a key safety property.
|
||
|
||
## D16. Must-port functions, integration order, risks, conformance tests
|
||
|
||
### Must-port / must-align functions (with decomp addresses)
|
||
|
||
Physics membership (mostly *already present* in acdream — align, don't rewrite):
|
||
|
||
| Retail function | Address (pseudo_c) | acdream status / action |
|
||
|---|---|---|
|
||
| `CTransition::validate_transition` | `0x0050aa70` (272547) | Present as `ValidateTransition` (`TransitionTypes.cs:3398`). **Keep.** |
|
||
| `CObjCell::find_cell_list` | `0x0052b4e0` (308742) | Present as `CellTransit.BuildCellSetAndPickContaining`. **Add interior-wins break + `do_not_load_cells` prune (`:308814-308867`).** |
|
||
| `CTransition::check_other_cells` | `0x0050ae50` (272717) | Present as `CheckOtherCells` + mid-sweep retarget (`TransitionTypes.cs:2061-2075`). **Keep; ensure exit→`adjust_to_outside` path exists.** |
|
||
| `CPhysicsObj::SetPositionInternal(CTransition*)` | `0x00515330` (283399) | **Port the read-`CurCell`/commit-on-differ contract into `ResolveWithTransition`** — return `sp.CurCellId`, drop static `ResolveCellId` at `PhysicsEngine.cs:909/928`. |
|
||
| `CPhysicsObj::change_cell` | `0x00513390` (281192) | acdream uses a spatial registry, not per-cell shadow lists. **Emit a "cell changed" event when `sp.CurCellId` differs**; align registration with retail's per-cell `shadow_object_list` over time (see memory `feedback_retail_per_cell_shadow_list.md`). |
|
||
| `LandDefs::adjust_to_outside` | `0x005a9bc0` (438719) | Needed for the interior→outdoor exit id (`check_other_cells:272783`). **Verify acdream's `AddAllOutsideCells` / `Position.get_outside_cell_id` equivalent covers this.** |
|
||
| `CBuildingObj::find_building_transit_cells` | `0x006b5230` (701214) | Present as `CheckBuildingTransit`. **Keep.** |
|
||
| `CEnvCell::find_transit_cells` (sphere) | `0x0052c820` (309968) | Present as `FindTransitCellsSphere`. **Keep.** |
|
||
| `CLandCell::add_all_outside_cells` | `0x00533630` (317499) | Present as `AddAllOutsideCells`. **Keep** (see memory `feedback_latent_bug_masked_by_fallback.md` re: coord convention). |
|
||
|
||
Render visibility (port fresh — this is the unified-pipeline work, Phase U/W3):
|
||
|
||
| Retail function | Address (pseudo_c) | acdream action |
|
||
|---|---|---|
|
||
| `SmartBox::RenderNormalMode` | `0x00453aa0` (92635) | Port the single inside/outside decision keyed off `CurrCell` id-range + `seen_outside`. |
|
||
| `CellManager::ChangePosition` | `0x004559b0` (94601) | Port: render resolves cell from `objcell_id` via `CellGraph.GetVisible`; `grab_visible_cells` on change; keep-landscape when `seen_outside`. |
|
||
| `CEnvCell::grab_visible_cells` | `0x0052e220` (311878) | Port: add self+`stab_list` to visible set; load landscape iff `seen_outside`. |
|
||
| `PView::ConstructView` (cell) | `0x005a57b0` (433750) | Port the BFS visible-cell-list builder. |
|
||
| `PView::InitCell` | `0x005a4b70` (432896) | Port per-portal sidedness/clip init. |
|
||
| `PView::ClipPortals` | `0x005a5520` (433572) | Port; **the `other_cell_id == 0xffffffff` branch (`:433662`) is the landscape-through-doorway hook.** |
|
||
| `PView::AddViewToPortals` | `0x005a52d0` (433446) | Port neighbour enqueue + `SetOtherSeen`. |
|
||
| `PView::DrawCells` | `0x005a4840` (432709) | Port: `if outside_view.view_count>0 → LScape::draw` first; conditional Z-clear (NOT color); per-cell `drawing_bsp`; exit-portal stencil. |
|
||
| `PView::DrawInside` | `0x005a5860` (433793) | Port the top-level indoor entry. |
|
||
| `PView::ConstructView` (bld portal) | `0x005a59a0` (433827) | Port the outdoor→building recursion (camera sees door from street). |
|
||
|
||
### Integration order (lowest-risk first)
|
||
|
||
1. **W2/W3-physics: stop discarding the swept cell.** Make
|
||
`ResolveWithTransition` return `sp.CurCellId`; route it to
|
||
`PlayerMovementController.CellId` and `CellGraph.CurrCell`. Delete the static
|
||
`ResolveCellId` call at `:909/928` (keep `ResolveCellId` only for non-sweep
|
||
teleport/spawn seeding). *Smallest, highest-leverage change; does not touch
|
||
collision math.* Expect the boundary flicker to vanish immediately.
|
||
2. **CellTransit pick fix.** Add interior-wins short-circuit + the
|
||
`do_not_load_cells` prune in `BuildCellSetAndPickContaining`. Then delete the
|
||
`DoorwayHoldMargin` hold and the sphere-overlap re-verify in `ResolveCellId`
|
||
(now dead for the per-tick path). *Removes band-aids; makes pick
|
||
deterministic.*
|
||
3. **Render reads `CurrCell`.** Re-root `CellVisibility` at
|
||
`CellGraph.CurrCell` (player cell), remove `FindCameraCell`'s independent
|
||
re-derive + the 3-frame grace hack. *This alone fixes the threshold strobe.*
|
||
4. **Single PVS + landscape-through-door.** Port `PView::ConstructView` /
|
||
`DrawCells` so the indoor pass draws the landscape through exit-portal clips
|
||
(kills the blue hole) and the single `RenderNormalMode` decision replaces the
|
||
two-pipe split. *Largest piece; do last, gated on visual verification.*
|
||
5. **(Longer term) per-cell shadow lists.** Migrate collision registration from
|
||
the landblock-wide spatial registry to retail's per-cell `shadow_object_list`
|
||
to close the indoor/outdoor seam issues noted in memory.
|
||
|
||
### Risks
|
||
|
||
- **Velocity/position consumers of the cell.** Anything that today reads the
|
||
re-derived cell (streaming radius, audio cell, picking) must switch to
|
||
`CurrCell`. Audit `SetCurrAndReturn` call sites. *Mitigation:* make the change
|
||
event explicit; one writer.
|
||
- **Spawn/teleport seeding still needs a static resolve.** Keep `ResolveCellId`
|
||
for `MoveOrTeleport`-equivalent seeding (retail's `SetPositionInternal(Position*,…)`
|
||
path at `pseudo_c:283892` also resolves from scratch). Don't delete it
|
||
wholesale — only remove it from the per-tick *transition output*.
|
||
- **`do_not_load_cells` prune over-pruning.** If acdream's `StabList` for a cell
|
||
is incomplete (dat parse gap), the prune could drop a legitimately-overlapping
|
||
neighbour and cause a wall to be missed. *Mitigation:* the prune only runs for
|
||
interior seeds and only removes non-stab cells; verify `EnvCell.StabList`
|
||
population against `datCell.VisibleCells` (already done in `EnvCell.FromDat`).
|
||
- **PVS perf / portal-graph blowup (issue #95).** Port `master_timestamp` +
|
||
`cell_view_done` cycle-guards from `PView` to bound the BFS; cap with
|
||
`max_indist`. *This is exactly what acdream's separate BFS lacks.*
|
||
- **Render root vs camera.** Retail's `viewer_cell` is the *camera* cell;
|
||
acdream's chase camera drifts out of the player cell ~79% of frames (CLAUDE.md
|
||
U.4c), so **root the PVS at the player cell** (the shipped flap fix), keeping
|
||
the eye for projection only. Do not regress to camera-rooted visibility.
|
||
|
||
### Conformance tests (prove faithfulness)
|
||
|
||
1. **Membership latch (no static re-derive).** Replay the captured cottage/
|
||
cellar trajectory (`CellarUpTrajectoryReplayTests` + `ACDREAM_CAPTURE_RESOLVE`
|
||
JSONL) and assert the per-tick output cell equals the *swept* `sp.CurCellId`,
|
||
never a re-derive. Specifically: a held-still tick at the doorway must keep
|
||
the prior cell (no `0x0170↔0x0031` flip). Golden source: live capture shows
|
||
1 cell-transit (login) vs 20+ pre-fix (CLAUDE.md A6.P3 slice 3).
|
||
2. **Block does not change cell.** Unit test: seed `CurCell=A`, run a transition
|
||
whose only accepted sub-steps stay in A but whose final push-back nudges the
|
||
center ~8 cm across the A/B boundary; assert `sp.CurCellId == A` and the
|
||
committed cell == A. (Directly tests `validate_transition` reset path.)
|
||
3. **Interior-wins pick.** Unit test: sphere center inside both a doorway
|
||
landcell and the vestibule EnvCell; assert `BuildCellSetAndPickContaining`
|
||
returns the EnvCell and short-circuits.
|
||
4. **`do_not_load_cells` prune.** Unit test: interior seed cell with a known
|
||
`StabList`; inject an outdoor candidate not in the stab list; assert it is
|
||
removed. Mirror ACE's `find_cell_list:387-412`.
|
||
5. **Exit-to-outdoor id.** Unit test: from an interior cell with an exit portal,
|
||
walk the foot sphere out the door; assert `check_other_cells` produces the
|
||
correct outdoor landcell id via `adjust_to_outside`.
|
||
6. **Render PVS == physics cell.** Integration test: assert the PVS root cell id
|
||
equals `CellGraph.CurrCell.Id` every frame (no independent camera re-derive).
|
||
7. **Seamless seal (visual + headless).** Headless: in a cottage with a known
|
||
exit portal, assert `PView.outside_view.view_count > 0` and that `LScape::draw`
|
||
equivalent is invoked (terrain drawn) — i.e. no blue clear-color in the
|
||
doorway region. Visual: the user confirms sky/rain visible through the door,
|
||
ceiling sealed, no terrain bleed. (Visual verification is the acceptance gate
|
||
per CLAUDE.md.)
|
||
8. **Dungeon: no terrain/sky.** Headless: in a `seen_outside == 0` EnvCell graph
|
||
(dungeon), assert the landscape is never grabbed (`grab_visible_cells` returns
|
||
before `LScape::grab_visible_cells`) and `outside_view.view_count == 0`.
|
||
|
||
---
|
||
|
||
## Appendix — disagreements & uncertainties
|
||
|
||
- **`seen_outside` field overload in `CEnvCell::UnPack`.** At
|
||
`pseudo_c:311044` the decompiler shows `this->seen_outside = <operator new[]>`
|
||
and indexes it with frames — inconsistent with the verbatim header's
|
||
`int seen_outside`. This is BN's heuristic field-naming colliding with an
|
||
adjacent allocation (likely the `voyeur_table`/light-frame array). The
|
||
**authoritative** facts are (a) the verbatim header `int seen_outside`
|
||
(`acclient.h:30929`), (b) its boolean use in render (`pseudo_c:92649,94575,
|
||
311893`), and (c) the dat flag `EnvCellFlags.SeenOutside = 0x1`. I treat
|
||
`seen_outside` as a per-cell boolean. **[flagged as decomp-naming noise]**
|
||
- **`edi_2` in `RenderNormalMode`.** The decompiler garbles the "viewer is in an
|
||
outdoor landcell" predicate (`pseudo_c:92644-92646`). The branch is clearly
|
||
binary (`edi_2 == 0` → `DrawInside`, else `LScape::draw`), and the
|
||
`viewer_cell->seen_outside` term is intact. I infer `edi_2 != 0` ⇔ viewer cell
|
||
is a landcell. **[INFERRED from branch structure + the parallel
|
||
`CellManager::ChangePosition` `ebx = (objcell_id < 0x100)` test at
|
||
`pseudo_c:94568`]**
|
||
- **`do_not_load_cells` set-site for the player transition.** I verified the
|
||
*flag's effect* (`find_cell_list:308829`) and ACE's `LoadCells` semantics, but
|
||
did not pin the exact retail call that sets it on the per-tick player array.
|
||
The prune is unconditionally correct for interior seeds, so acdream can enable
|
||
it for interior membership resolution regardless. **[INFERRED]**
|
||
- **References diverge on the seal.** ACViewer (brute-force draw all EnvCells +
|
||
`DungeonMode` cull toggle) and WorldBuilder (flat stencil inside-out) do **not**
|
||
implement `PView`'s portal-clipped landscape-through-door. The decomp is the
|
||
sole authority for C9–C11, and it wins. **[VERIFIED divergence]**
|
||
|
||
---
|
||
|
||
### Citation index (primary decomp anchors)
|
||
|
||
```
|
||
CPhysicsObj::transition 0x00512dc0 pseudo_c:280904
|
||
SPHEREPATH::init_path 0x0050ce20 pseudo_c:274359
|
||
CTransition::find_valid_position 0x0050c310 pseudo_c:273890
|
||
CTransition::find_transitional_position 0x0050bdf0 pseudo_c:273613
|
||
CTransition::transitional_insert 0x0050b6f0 pseudo_c:273137
|
||
CTransition::insert_into_cell 0x00509e70 pseudo_c:271991
|
||
CTransition::check_other_cells 0x0050ae50 pseudo_c:272717
|
||
CTransition::validate_transition 0x0050aa70 pseudo_c:272547
|
||
CObjCell::find_cell_list (6-arg) 0x0052b4e0 pseudo_c:308742
|
||
CObjCell::GetVisible 0x0052ad40 pseudo_c:308209
|
||
CEnvCell::GetVisible 0x0052dc10 pseudo_c:311378
|
||
CLandCell::GetVisible 0x00532db0 pseudo_c:316986
|
||
CEnvCell::point_in_cell 0x0052c300 pseudo_c:309677
|
||
CLandCell::point_in_cell 0x00532d40 pseudo_c:316941
|
||
CEnvCell::find_transit_cells (sphere) 0x0052c820 pseudo_c:309968
|
||
CLandCell::find_transit_cells 0x00533800 pseudo_c:317603
|
||
CSortCell::find_transit_cells 0x00534060 pseudo_c:318309
|
||
CBuildingObj::find_building_transit_cells 0x006b5230 pseudo_c:701214
|
||
CLandCell::add_all_outside_cells 0x00533630 pseudo_c:317499
|
||
LandDefs::adjust_to_outside 0x005a9bc0 pseudo_c:438719
|
||
Position::get_outside_cell_id 0x004527b0 pseudo_c:91552
|
||
CPhysicsObj::SetPositionInternal(CTrans)0x00515330 pseudo_c:283399
|
||
CPhysicsObj::change_cell 0x00513390 pseudo_c:281192
|
||
CPhysicsObj::UpdateObjectInternal 0x005156b0 pseudo_c:283611
|
||
CEnvCell::find_visible_child_cell 0x0052dc50 pseudo_c:311397
|
||
CEnvCell::grab_visible_cells 0x0052e220 pseudo_c:311878
|
||
SmartBox::RenderNormalMode 0x00453aa0 pseudo_c:92635
|
||
CellManager::ChangePosition 0x004559b0 pseudo_c:94601
|
||
PView::ConstructView (CEnvCell) 0x005a57b0 pseudo_c:433750
|
||
PView::ConstructView (CBldPortal) 0x005a59a0 pseudo_c:433827
|
||
PView::InitCell 0x005a4b70 pseudo_c:432896
|
||
PView::ClipPortals 0x005a5520 pseudo_c:433572
|
||
PView::AddViewToPortals 0x005a52d0 pseudo_c:433446
|
||
PView::DrawCells 0x005a4840 pseudo_c:432709
|
||
PView::DrawInside 0x005a5860 pseudo_c:433793
|
||
|
||
Structs (acclient.h):
|
||
SPHEREPATH 32625 | CObjCell 30915 | CEnvCell 32072 | CLandCell 31886
|
||
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 | CELLARRAY 31574
|
||
CELLINFO 31925 | CLandBlockInfo 31893 | CBuildingObj 31908 | portal_view_type 32346
|
||
|
||
References cross-checked:
|
||
ACE ObjCell.find_cell_list references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:335
|
||
ACE Transition.ValidateTransition references/ACE/Source/ACE.Server/Physics/Transition.cs:984
|
||
ACE PhysicsObj.SetPositionInternal references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:1171
|
||
ACE Landblock.IsDungeon references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs:575
|
||
ACViewer EnvCellFlags references/ACViewer/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs:7
|
||
ACViewer Buffer (no PVS) references/ACViewer/ACViewer/Render/Buffer.cs:122,340,564
|
||
|
||
acdream current code:
|
||
PhysicsEngine.ResolveCellId src/AcDream.Core/Physics/PhysicsEngine.cs:294
|
||
PhysicsEngine static re-derive src/AcDream.Core/Physics/PhysicsEngine.cs:909,928
|
||
SpherePath CurCellId/CheckCellId src/AcDream.Core/Physics/TransitionTypes.cs:335-336
|
||
ValidateTransition (advance/reset) src/AcDream.Core/Physics/TransitionTypes.cs:3398-3434
|
||
CellTransit pick (no prune) src/AcDream.Core/Physics/CellTransit.cs:426-538
|
||
CellVisibility.FindCameraCell src/AcDream.App/Rendering/CellVisibility.cs:389 (+ grace :214)
|
||
CellGraph (unified, W1) src/AcDream.Core/World/Cells/CellGraph.cs
|
||
EnvCell.FromDat (StabList/SeenOutside) src/AcDream.Core/World/Cells/EnvCell.cs:42
|
||
```
|