acdream/docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md

380 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.