380 lines
18 KiB
Markdown
380 lines
18 KiB
Markdown
# OH1 retail construction and landscape-order contract
|
||
|
||
Date: 2026-09-01
|
||
|
||
Scope: the canonical cell-membership owner and the landscape/building/object
|
||
draw order. This packet does not decide built-mesh clipping, CellStruct subset
|
||
selection, or the `portalsDrawnCount` lifecycle; the sibling OH1/OH2 packets
|
||
own those facts.
|
||
|
||
## Exact questions
|
||
|
||
1. What does retail construct when an EnvCell static is initialized?
|
||
2. Which object, part, cell, and child lists own render membership?
|
||
3. Which exact bounds cross a portal during static membership construction?
|
||
4. In what order does retail visit landscape views, blocks, cells, terrain,
|
||
buildings, ordinary cell objects, alpha barriers, and weather?
|
||
5. Where does current acdream depart from that ownership and order?
|
||
|
||
## Sources and arbitration
|
||
|
||
Primary text:
|
||
|
||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||
- `docs/research/named-retail/acclient.h`
|
||
- `docs/research/named-retail/acclient.c`
|
||
|
||
Prior independently verified evidence used for cross-checking:
|
||
|
||
- `docs/research/2026-06-11-holistic-map/wf1-statics-dynamics.md`
|
||
- `docs/research/2026-08-30-fw-walk-pseudocode.md`
|
||
- `docs/research/2026-08-30-fw-walk-pseudocode-appendix.md`
|
||
|
||
Every branch in the contract below was re-read on 2026-09-01 through the live
|
||
Ghidra HTTP bridge at `127.0.0.1:8081`, `patchmem.gpr`, using
|
||
`/decompile_function?address=...`. Ghidra returned the named 2013 build and
|
||
agreed with the named-retail control flow for:
|
||
|
||
- `CEnvCell::init_static_objects` `0x0052c350`
|
||
- `CPhysicsObj::add_obj_to_cell` `0x005159e0`
|
||
- `CPhysicsObj::calc_cross_cells_static` `0x00515160`
|
||
- `CPhysicsObj::find_bbox_cell_list` `0x00510fc0`
|
||
- `CPartArray::calc_cross_cells_static` `0x00518160`
|
||
- `CEnvCell::find_transit_cells(parts)` `0x0052cae0`
|
||
- `CPhysicsObj::add_shadows_to_cells` `0x00514ae0`
|
||
- `CPartArray::AddPartsShadow` `0x00517e40`
|
||
- `CPhysicsObj::remove_shadows_from_cells` `0x00511230`
|
||
- `LScape::draw` `0x00506330`
|
||
- `LScape::draw_check_blocks` `0x00505f80`
|
||
- `LScape::landcell_check` `0x005050a0`
|
||
- `RenderDeviceD3D::DrawBlock` `0x005a17c0`
|
||
- `RenderDeviceD3D::DrawSortCell` `0x0059f140`
|
||
- `RenderDeviceD3D::DrawObjCell` `0x005a1a40`
|
||
- `RenderDeviceD3D::DrawBuilding` `0x0059f2a0`
|
||
- `CShadowPart::insertion_sort` `0x006b5130`
|
||
|
||
No behavior below is inferred from a screenshot.
|
||
|
||
## Struct facts used by the contract
|
||
|
||
The following offsets are exact for the standalone 32-bit records in
|
||
`acclient.h`; inherited class fields are named because the PDB-backed Ghidra
|
||
decompile resolves them directly.
|
||
|
||
| Type/field | Offset | Meaning |
|
||
|---|---:|---|
|
||
| `CELLARRAY.added_outside` | `+0x00` | whether an exterior portal was crossed |
|
||
| `CELLARRAY.do_not_load_cells` | `+0x04` | transit lookups may not synchronously load missing cells |
|
||
| `CELLARRAY.num_cells` | `+0x08` | ordered member count |
|
||
| `CELLARRAY.cells` | `+0x0C` | `DArray<CELLINFO>` preserving insertion order |
|
||
| `CPartCell.vfptr` | `+0x00` | `add_part` / `remove_part` owner |
|
||
| `CPartCell.num_shadow_parts` | `+0x04` | render-part count |
|
||
| `CPartCell.shadow_part_list` | `+0x08` | ordered `DArray<CShadowPart*>` |
|
||
| `CShadowPart.num_planes` | `+0x00` | number of portal clip-plane lists |
|
||
| `CShadowPart.planes` | `+0x04` | borrowed `ClipPlaneList**` |
|
||
| `CShadowPart.frame` | `+0x08` | owning cell-relative frame |
|
||
| `CShadowPart.part` | `+0x0C` | exact `CPhysicsPart*` |
|
||
| `CShadowObj.physobj` | after `LongHashData` | collision/object-list owner |
|
||
| `CShadowObj.cell_id` | next field | registered cell identity |
|
||
| `CShadowObj.cell` | next field | exact registered cell pointer |
|
||
|
||
Named inherited fields which are load-bearing here:
|
||
|
||
- `CPhysicsObj.part_array`, `parent`, `children`, `m_position`, `cell`,
|
||
`num_shadow_objects`, `shadow_objects`, and `state`.
|
||
- `CPartArray.num_parts` and `parts`.
|
||
- `CObjCell.clip_planes`, `stab_list`, `seen_outside`, and the inherited
|
||
`CPartCell.shadow_part_list`.
|
||
- `CEnvCell.num_static_objects`, `static_object_ids`,
|
||
`static_object_frames`, and `static_objects`.
|
||
- `CLandBlock.side_cell_count`, `lcell`, `in_view`, `draw_array`, and
|
||
`draw_array_size`. `CLandCell` stride is `0x108`; its `in_view` is `+0x104`.
|
||
- `LScape.mid_width`, `land_blocks`, `block_draw_list`, viewer offsets, and
|
||
`sky`.
|
||
|
||
## Contract A: EnvCell static construction
|
||
|
||
Retail call order is fixed:
|
||
|
||
```text
|
||
CEnvCell::init_static_objects(cell)
|
||
if static_objects == null:
|
||
allocate exactly num_static_objects pointers
|
||
for each authored static in source order:
|
||
id == 0 ? null : CPhysicsObj::makeObject(id, 0, 0)
|
||
non-null -> CPhysicsObj::add_obj_to_cell(obj, cell, authored Frame)
|
||
else:
|
||
for each existing non-null static in source order:
|
||
if !obj->is_completely_visible(): obj->calc_cross_cells_static()
|
||
|
||
CPhysicsObj::add_obj_to_cell(obj, cell, frame)
|
||
enter_cell(cell)
|
||
copy authored Frame into obj->m_position.frame
|
||
if not PARTICLE_EMITTER_PS and part_array != null: part_array->SetFrame(frame)
|
||
UpdateChildrenInternal()
|
||
calc_cross_cells_static()
|
||
```
|
||
|
||
This proves the `static_object_ids/static_object_frames` arrays are an
|
||
authored spawn manifest. They are not the draw list. Once constructed, the
|
||
static follows the same `CPhysicsObj`/`CPartArray` membership machinery as an
|
||
ordinary object.
|
||
|
||
### `calc_cross_cells_static` branch table
|
||
|
||
Ghidra at `0x00515160` resolves the branch exactly:
|
||
|
||
| Condition | Retail route |
|
||
|---|---|
|
||
| `(state & 0x10000) == 0 && part_array != null && GetNumCylsphere() != 0` | `CObjCell::find_cell_list(position, cylspheres, CELLARRAY, null)` |
|
||
| otherwise | `find_bbox_cell_list(obj, CELLARRAY)` |
|
||
|
||
Before either route retail performs, in order:
|
||
|
||
```text
|
||
cell_array.num_cells = 0
|
||
cell_array.added_outside = 0
|
||
cell_array.do_not_load_cells = 1
|
||
```
|
||
|
||
After either route retail unconditionally calls
|
||
`remove_shadows_from_cells(obj)` and then
|
||
`add_shadows_to_cells(obj, &cell_array)`.
|
||
|
||
The bbox route is not an aggregate object AABB flood. It begins with the
|
||
current `obj->cell`, then iterates the growing ordered CELLARRAY. For each live
|
||
cell it calls `CPartArray::calc_cross_cells_static`, whose complete body is a
|
||
virtual `cell->find_transit_cells(num_parts, parts, cell_array)` call.
|
||
|
||
For a `CEnvCell`, Ghidra `0x0052cae0` proves that transit is per portal and per
|
||
visual part:
|
||
|
||
1. Take a part's `gfxobj->physics_sphere`; if null, take its
|
||
`gfxobj->drawing_sphere`; if both are null, that part cannot open a portal.
|
||
2. Transform the sphere center from the part frame into the current cell.
|
||
3. Test signed portal-plane distance with radius plus `F_EPSILON` and the
|
||
authored `portal_side`.
|
||
4. For a possible crossing, obtain `CPhysicsPart::GetBoundingBox(part)`,
|
||
transform that box into the current cell, and require
|
||
`Plane::intersect_box(box) != portal_side`.
|
||
5. For an indoor neighbor, resolve it with
|
||
`CCellPortal::GetOtherCell(cell_array.do_not_load_cells)`, transform the
|
||
same part box into the neighbor, and require
|
||
`neighbor->structure->box_intersects_cell(box)` before ordered
|
||
`CELLARRAY::add_cell`.
|
||
6. For `other_cell_id == 0xFFFFFFFF`, remember an outside crossing. After all
|
||
portals, call `CLandCell::add_all_outside_cells(num_parts, parts,
|
||
cell_array)` once.
|
||
|
||
The `do_not_load_cells=1` state controls lookup only. This bbox route contains
|
||
no later rule that removes the outside cells it just added.
|
||
|
||
## Contract B: one graph owns collision and render membership
|
||
|
||
`CPhysicsObj::add_shadows_to_cells` `0x00514ae0` consumes the ordered
|
||
CELLARRAY twice for non-particle objects:
|
||
|
||
1. It sizes `shadow_objects`, creates one `CShadowObj` per cell, assigns the
|
||
same owning `CPhysicsObj`, and copies each CELLARRAY `cell_id` in order.
|
||
2. It visits the same cells in the same order. For every live cell it calls
|
||
`CObjCell::add_shadow_object(cell, shadow, num_cells)` and then, when a
|
||
`part_array` exists, `CPartArray::AddPartsShadow(part_array, cell,
|
||
num_shadow_objects)`.
|
||
|
||
`CPartArray::AddPartsShadow` `0x00517e40` selects the clip owner exactly:
|
||
|
||
```text
|
||
ClipPlaneList** planes = num_shadow_objects > 1 ? cell->clip_planes : null
|
||
for each part in part-array order:
|
||
if part != null: cell->add_part(part, planes, &cell->pos.frame, num_shadow_objects)
|
||
```
|
||
|
||
Therefore collision-side `shadow_object_list` and render-side
|
||
`shadow_part_list` are sibling products of one CELLARRAY transaction. They are
|
||
not separately reconstructed lists. Each render entry identifies an exact
|
||
part, not merely an aggregate object or Setup owner.
|
||
|
||
The particle branch is exact and different: `(state & 0x1000) != 0` calls
|
||
`add_particle_shadow_to_cell`, registering in only `this->cell`.
|
||
|
||
After the root, `add_shadows_to_cells` recursively passes the same CELLARRAY
|
||
to every object in `children`, in child-list order. This is the exact equipped
|
||
child inheritance rule.
|
||
|
||
Removal is the inverse transaction. `remove_shadows_from_cells` `0x00511230`
|
||
visits every current `CShadowObj` in order, removes it from its exact cell,
|
||
calls `CPartArray::RemoveParts(part_array, cell)`, clears the root count, and
|
||
then recurses through children. Publication and removal are therefore
|
||
symmetrical at the same graph owner.
|
||
|
||
## Contract C: landscape visibility and painter order
|
||
|
||
`LScape::draw` `0x00506330` performs exactly:
|
||
|
||
```text
|
||
if sky != null: GameSky::Draw(sky, 0)
|
||
if block_draw_list != null:
|
||
draw_check_blocks()
|
||
for block_draw_list from last index down to zero:
|
||
if block != null && block->in_view != OUTSIDE:
|
||
RenderDevice::DrawBlock(block)
|
||
if sky != null && weather_enabled: GameSky::Draw(sky, 1)
|
||
```
|
||
|
||
It is called once for one active PView. Multiple portal views do not cause
|
||
multiple full landscape passes.
|
||
|
||
### Visibility union
|
||
|
||
`draw_check_blocks` `0x00505f80` first clears every block and cell `in_view`.
|
||
It then iterates every view in `Render::PortalList`; a null/zero-view list
|
||
executes one default-view iteration. For each view it calls
|
||
`Render::set_view`, evaluates every block with the shared clip-height interval
|
||
grid, and calls `landcell_check` for blocks not outside.
|
||
|
||
`landcell_check` `0x005050a0` is exact:
|
||
|
||
- full `8×8` block and `ENTIRELY_INSIDE`: set every cell to
|
||
`ENTIRELY_INSIDE`;
|
||
- full `8×8` partial block: compute the 24 m per-cell interval grid and write
|
||
a cell only when its current `IsInView()` is zero;
|
||
- coarse LOD block (`side_cell_count != 8`): mark every represented cell
|
||
`PARTIALLY_INSIDE`.
|
||
|
||
The `IsInView()==0` precheck makes multiple views a visibility union. A later
|
||
view cannot downgrade a cell admitted by an earlier one.
|
||
|
||
### Block/cell leaf order
|
||
|
||
`RenderDeviceD3D::DrawBlock` `0x005a17c0` has two passes over the block's
|
||
precomputed far-to-near `draw_array`:
|
||
|
||
1. For each visible cell: `UpdateObjCell`; if it has more than one shadow
|
||
part, `CShadowPart::insertion_sort`.
|
||
2. For each draw-array cell:
|
||
- if visible, `DrawLandCell` first;
|
||
- if `alwaysDrawObjects != 0` or visible, `DrawSortCell` second;
|
||
- after that cell, conditionally `FlushAlphaList(flush)`.
|
||
|
||
`DrawSortCell` `0x0059f140` is exactly:
|
||
|
||
```text
|
||
if cell->building != null: DrawBuilding(cell->building)
|
||
DrawObjCell(cell)
|
||
```
|
||
|
||
`DrawObjCell` delegates to `DrawPartCell` over that cell's real
|
||
`shadow_part_list`. The building precedes ordinary statics/dynamics in the
|
||
same landscape cell.
|
||
|
||
`CShadowPart::insertion_sort` `0x006b5130` walks the array backward and shifts
|
||
entries while the next part's `CYpt` is greater. It produces stable
|
||
far-to-near part order; equal distances do not swap.
|
||
|
||
`DrawBuilding` `0x0059f2a0` first publishes its portals, updates its shell
|
||
part's degrade, and skips the complete body when the selected GfxObj pointer
|
||
is null. Inside the gate its order is:
|
||
|
||
```text
|
||
FlushAlphaList(0.0)
|
||
CPhysicsPart::Draw(shellPart, 1) // portal walk: punches/look-ins
|
||
ObjBuildingOrBuildingPart = 1
|
||
CPhysicsPart::Draw(shellPart, 0) // plain shell mesh
|
||
ObjBuildingOrBuildingPart = 0
|
||
```
|
||
|
||
Thus the complete landscape painter sequence is:
|
||
|
||
```text
|
||
sky pre-pass
|
||
visibility union over all active views
|
||
for blocks far-to-near:
|
||
pre-update and stable-sort every visible cell's real shadow parts
|
||
for cells far-to-near:
|
||
terrain leaf
|
||
building: alpha drain -> portal look-ins/punches -> shell
|
||
ordinary cell shadow parts
|
||
threshold alpha flush
|
||
weather/sky post-pass
|
||
```
|
||
|
||
## Current acdream comparison
|
||
|
||
| Retail fact | Current acdream | Disposition |
|
||
|---|---|---|
|
||
| EnvCell static construction installs a `CPhysicsObj` and publishes collision + exact per-part render membership from one CELLARRAY transaction. | `WalkProductionWorldData.BeginFrame` re-sweeps `RenderSceneIndex.IndoorCellStatic` and reconstructs a separate App-owned render index each frame. | Major duplicate owner. OH3 must publish/borrow exact part membership at the canonical collision/world graph boundary and delete the reconstruction. |
|
||
| Bbox static transit is per visual part, using physics/drawing sphere for portal rejection and exact part bounding box for intersection/neighbor containment. | `ResolveStaticRenderCells` calls `ShadowShapeBuilder.FromStaticRenderParts`, converts prepared visual bounds into `ShadowShape`s, and drives `ComputeStaticRenderCells`; primitive Setups may instead reuse collision cells. | Candidate approximation. It is useful evidence, not the final retail owner. Recipe-7's DrawingBSP root sphere does not by itself prove the exact per-part box publication is present. |
|
||
| Outside cells reached by the static bbox transit remain in the CELLARRAY. | Dirty `CellTransit` removed an outdoor prune globally, including collision callers. | Correct retail fact, wrong ownership boundary risk. OH3 must port the exact bbox route without mutating unrelated sphere/collision routes. |
|
||
| Dynamics and attached children inherit the root's exact ordered CELLARRAY and publish exact per-part entries. | `WalkProductionWorldData` buckets whole `RenderProjectionRecord`s by `ShadowObjectRegistry.GetOwnerCells`, resolving equipped children by a parent callback. The draw classifier later tries to restore per-part stamps. | Membership is reconstructed from the collision registry, loses the canonical `CShadowPart` transaction/order, and creates a second model. |
|
||
| `LScape::draw` runs once and visibility unions all views. | `RetailFrameWalk.DrawLandscape` correctly installs one view set and walks blocks/cells once. | Keep. |
|
||
| Per landscape cell: terrain -> building -> ordinary objects. | `WalkFrameDriver.HandleLandscapeTurn` emits one whole `TerrainSlice(0)` immediately after sky; all terrain is submitted before `RetailFrameWalk` later emits building/object cell turns. The source comment explicitly calls this an intra-stage simplification. | Confirmed OH5 defect. It can expose background/behind-wall content because retail's depth/painter interleave no longer exists. |
|
||
| Cell object draw consumes the cell's exact stable far-to-near `shadow_part_list`. | The driver materializes per-cell record buckets, classifies/batches them, and uses a scope-level ordered stream plus separate alpha collection. | OH3/OH7 must replace reconstructed membership/order with exact part entries and exact per-cell alpha barriers. |
|
||
| Building is alpha drain -> portal pass -> shell, then cell objects. | `RetailFrameWalk.DrawBuilding` and the driver's building events model this order. | Mechanism broadly matches, but its correctness depends on the sibling OH1 built-mesh/view and depth-lifecycle findings. |
|
||
|
||
The architecture SSOT currently says that the walk records one retail-ordered
|
||
stream including landscape cells, but the implementation and its own comments
|
||
admit that terrain is one whole pre-stage. That is an architecture/code
|
||
contradiction, not a documented adaptation. The same architecture section
|
||
also states that every admitted portal slice GPU-clips the mesh; the dirty
|
||
built-mesh candidate says the opposite. The sibling built-mesh evidence must
|
||
arbitrate that claim before the architecture text or production path is
|
||
treated as authoritative.
|
||
|
||
## Smallest exact ownership changes implied
|
||
|
||
This evidence does **not** authorize an immediate patch. It constrains later
|
||
slices:
|
||
|
||
1. OH3a must define one generation-scoped, prepared-data-backed graph whose
|
||
cell entries retain exact ordered part identity and the sibling
|
||
collision-object relationship.
|
||
2. OH3b/OH3c must publish/remove static, dynamic, particle, and child entries
|
||
at the same canonical transaction boundary, preserving CELLARRAY and part
|
||
order. App may expose borrowed immutable render views; it may not rebuild
|
||
membership from aggregate record AABBs each frame.
|
||
3. OH3d must delete `WalkProductionWorldData`'s static/dynamic membership
|
||
reconstruction after consumer cutover.
|
||
4. OH5 must replace the single whole-terrain event with real per-block,
|
||
per-cell terrain leaves interleaved before that cell's building/object
|
||
turn. It must preserve the existing exact block/cell order rather than
|
||
recompute a second order in the renderer.
|
||
5. OH7 must make the canonical per-cell part list and retail alpha flush sites
|
||
the only transparent-world order owner.
|
||
|
||
## Transcript fields required from this packet
|
||
|
||
The OH1 frame transcript must include:
|
||
|
||
- active PView/view index and visibility-union result per block/cell;
|
||
- block draw index and cell draw index;
|
||
- `DrawLandCell`, `DrawBuilding`, `DrawObjCell`, and alpha-flush events in one
|
||
total sequence;
|
||
- CELLARRAY member order and `added_outside/do_not_load_cells` state;
|
||
- root owner, child-inheritance source, exact part index/GfxObj, registered
|
||
cell, plane-list presence, and part-list insertion index;
|
||
- shadow removal in the same identity/order vocabulary;
|
||
- `CYpt` and the post-sort cell part order.
|
||
|
||
## Tests that must fail before the cutovers
|
||
|
||
- A two-cell static with two visual parts whose part boxes cross different
|
||
portals must produce the exact retail CELLARRAY and exact per-cell part
|
||
lists; an aggregate AABB implementation must fail the fixture.
|
||
- A child with no independent collision row must inherit its root's ordered
|
||
CELLARRAY and install each child part in the same cell order.
|
||
- A particle-emitter-state fixture must register only in the current cell.
|
||
- Removal must delete both object and part membership recursively with no
|
||
surviving render entry.
|
||
- A one-block landscape fixture with two cells must assert the sequence
|
||
`terrain(A), building(A), objects(A), terrain(B), objects(B)`. The current
|
||
whole-terrain pre-stage must fail it.
|
||
- A two-view landscape fixture must run one sky/landscape/weather pass while
|
||
unioning block/cell `in_view` across both views.
|
||
- Equal-`CYpt` parts must retain insertion order.
|
||
|
||
## Unresolved facts
|
||
|
||
None for the construction or landscape-order branches above. Exact built-mesh
|
||
submission per active view, draw-stamp scope, CellStruct surface selection,
|
||
and the previous-frame portal-depth latch remain deliberately unresolved here
|
||
and are owned by the sibling evidence packets.
|