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

18 KiB
Raw Blame History

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:

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:

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:

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:

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:

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:

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:

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 ShadowShapes, 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 RenderProjectionRecords 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.