From 1b631f127dfa14dee77074ed9f874a75e05486c8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 04:34:01 +0200 Subject: [PATCH] research: settle retail child-cell ownership (unblocks C4 route 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 7 wants to demote App's render-tick child rebucket (EquippedChildRenderController.TickChild) into Runtime. Whether that is correct depended entirely on whether retail propagates a parent's cell change to its children. It does — via two independent mechanisms, both on the physics path. Recursive: change_cell @0x00513390 -> enter_cell @0x00510ed0 recurses into children @0x00510f03 passing the same CObjCell* down, each frame writing m_position.objcell_id @0x00510f1e and cell @0x00510f35 and registering the child individually via add_object @0x00510ee2. leave_cell @0x00510f50 mirrors it @0x00510f84. Per-commit: SetPositionInternal(CTransition const*) @0x00515330 has an explicit depth-1 child loop @0x0051539c-@0x005153d8 writing child+0x4c from the parent's curr_pos.objcell_id @0x005153bd. The structural reason it must exist: update_object @0x00515d10 early-returns on parent != 0 @0x00515d40, so a parented child is NEVER independently simulated. The parent's tick is its only source of cell and frame. This corrects the routes-6-7 scoping doc, which said retail re-cells "inside set_parent". It does not — set_parent @0x00515a90 (both overloads read in full) contains no cell write and delegates to change_cell @0x00515ad6. recalc_cross_cells @0x00515a30 only READS objcell_id as a guard @0x00515a3f. The prior UpdateChild finding is CONFIRMED: @0x00512d50 -> set_frame @0x00514090 writes m_position.frame @0x005140e9 only. Consequence pinned for route 7's implementer: the Runtime replacement must write the child's cell at BOTH the set_parent analog AND the per-commit position analog. A set_parent-only write is correct at attach and stale on the parent's first cell crossing — which is the natural misreading of the earlier scoping. Also recorded: unset_parent @0x00513470 does zero cell work (full body read), leaving the child with a stale objcell_id still in that cell's object list; all six call sites resolve it externally via leave_world @0x005155a0 (which zeroes objcell_id @0x005155f4) or an explicit re-placement. +0x4c verified as m_position.objcell_id by closing the offset chain against acclient.h rather than trusting a Binary Ninja identifier. set_cell_id_recursive @0x00510da0 is a red herring — its only caller is sky-object handling @0x00506eba. Two adjacent facts remain NOT ESTABLISHED with the cdb breakpoints that would settle them; neither affects the verdict. Co-Authored-By: Claude Opus 5 --- .../2026-08-04-retail-child-cell-ownership.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/research/2026-08-04-retail-child-cell-ownership.md diff --git a/docs/research/2026-08-04-retail-child-cell-ownership.md b/docs/research/2026-08-04-retail-child-cell-ownership.md new file mode 100644 index 00000000..6955d11f --- /dev/null +++ b/docs/research/2026-08-04-retail-child-cell-ownership.md @@ -0,0 +1,381 @@ +# Retail child cell ownership — does a parent's cell change propagate to its children? + +Date: 2026-08-04 +Scope: Campaign C4 route 7 — settling whether the parented-child cell write +belongs in Runtime's `set_parent` analog. +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR +build, PDB-named) + `docs/research/named-retail/acclient.h` (verbatim retail +structs). Ghidra MCP was probed on 8080 and 8081 and was **not** live; the text +decomp is authoritative and was sufficient. + +**Verdict up front: YES, retail propagates a parent's cell to its children, and +it does so from the physics update path — twice over, on two different branches. +Route 7's premise is sound; App's render-tick rebucket is a divergence, not the +mechanism.** + +--- + +## 0. Struct offsets used below (verified, not assumed) + +Binary Ninja renders several of the child writes as raw offset arithmetic +(`*(uint32_t*)((char*)eax_2 + 0x4c)`), so the offsets were resolved from +`acclient.h` rather than trusted from a heuristic field name. + +`CPhysicsObj : LongHashData` (`acclient.h:30689`), `LongHashData : +HashBaseData` (`acclient.h:30149`), `HashBaseData += { vfptr, hash_next, id }` = 12 bytes (`acclient.h:30135`). + +| Offset | Field | Derivation | +|---|---|---| +| `0x0c` | `netblob_list` | base 12 | +| `0x10` | `part_array` | confirmed by use as `CPartArray*` at `0x005153c2` | +| `0x40` | `parent` | field order | +| `0x44` | `children` | field order | +| `0x48` | `m_position` | `Position : PackObj`, `PackObj` = `{ vfptr }` = 4 bytes (`acclient.h:26018`) | +| **`0x4c`** | **`m_position.objcell_id`** | `Position` = vfptr(4) + objcell_id(4) + `Frame`(64) = 72 (`acclient.h:30658`) | +| `0x90` | `cell` | `0x48 + 72` | +| `0xa8` | `state` | `cell`(4) + `num_shadow_objects`(4) + `DArray`(16); confirmed by the identical `[1] & 0x10` bit-test used against `this->state` in `enter_cell` | + +`Frame` = `qw,qx,qy,qz`(16) + `m_fl2gv[9]`(36) + `m_fOrigin`(12) = 64 +(`acclient.h:30647`). The chain closes exactly on `state == 0xa8`, so `+0x4c` is +`m_position.objcell_id` with no slack. + +There is **no `CPhysicsObj::set_cell`**. `symbols.json` contains only +`CPhysicsObj::set_cell_id` and `CPhysicsObj::set_cell_id_recursive`. The +cell-transition entry point is `change_cell`. + +--- + +## 1. Does a parent's cell change propagate to children? + +**Yes. Two independent mechanisms, both on the physics path.** + +### Mechanism A — the recursive cell walk (`change_cell` → `enter_cell`) + +`CPhysicsObj::change_cell @0x00513390`: + +``` +0051339b if (this->cell != 0) leave_cell(this, 1) @0x0051339f +005133aa if (arg2 != 0) { enter_cell(this, arg2); return; } @0x005133af +005133c1 this->m_position.objcell_id = 0 // arg2 == 0 path only +005133d8 this->cell = nullptr +``` + +`change_cell` itself has no child loop. Both of its callees do. + +`CPhysicsObj::enter_cell @0x00510ed0` — **recurses into children with the same +`CObjCell*`**: + +``` +00510ed8 if (this->part_array != 0) { +00510ee2 CObjCell::add_object(arg2, this) +00510eec for (edi_1 = 0; edi_1 < children->num_objects; edi_1++) +00510f03 CPhysicsObj::enter_cell(this->children->objects.data[edi_1], arg2) // RECURSION +00510f1b id = arg2->m_DID.id +00510f1e this->m_position.objcell_id = id // WRITE +00510f2b CPartArray::SetCellID(part_array, id) +00510f35 this->cell = arg2 // WRITE +00510f3e CPartArray::AddLightsToCell(part_array, arg2) + } +``` + +Because `arg2` is passed down unchanged, every descendant receives the parent's +**exact** cell object and therefore the identical `objcell_id`. Each descendant +is also individually registered into the cell via `CObjCell::add_object` +`@0x00510ee2` on its own recursion frame — children are real members of the +cell's object list, not merely nominally re-tagged. + +`CPhysicsObj::leave_cell @0x00510f50` mirrors it: + +``` +00510f5e CObjCell::remove_object(cell, this) +00510f84 for each child: CPhysicsObj::leave_cell(child, arg2) // RECURSION +00510fa7 this->cell = nullptr +``` + +Note `leave_cell` clears `cell` but does **not** zero `objcell_id`, on the +parent or on children. The stale id survives until `enter_cell` overwrites it. + +**Caveat, cited:** `enter_cell` is gated on `this->part_array != 0` +`@0x00510ed8`. A child with a null `part_array` receives no cell write and its +own children are not visited. `leave_cell` has no such gate. + +### Mechanism B — the explicit child loop in the motion commit + +`CPhysicsObj::SetPositionInternal(CPhysicsObj*, CTransition const*) +@0x00515330` is the per-commit position write. It branches on whether the +`CObjCell` pointer changed: + +``` +0051534a curr_cell = arg2->sphere_path.curr_cell +0051536d if (this->cell == curr_cell) { // BRANCH A: same cell object +00515385 this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id +00515392 CPartArray::SetCellID(part_array, objcell_id) +0051539c for (ebx_1 = 0; ebx_1 < this->children->num_objects; ebx_1++) { +005153ae eax_2 = this->children->objects.data[ebx_1] +005153ba objcell_id_1 = arg2->sphere_path.curr_pos.objcell_id +005153bd *(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1 // child->m_position.objcell_id +005153cc CPartArray::SetCellID(eax_3 /* child +0x10 */, objcell_id_1) + } + } else +00515372 CPhysicsObj::change_cell(this, curr_cell) // BRANCH B: → Mechanism A +005153e0 CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame) +``` + +So the child's `objcell_id` is re-stamped with the parent's on **every position +commit**, whether or not the `CObjCell` pointer changed. Branch A writes it +unconditionally — it does not test whether the value differs. + +**Two precise asymmetries between the branches, both citable:** +- Branch A's loop is **flat, depth-1 only** (`@0x005153ae`–`@0x005153d8` reads + `this->children` and writes `+0x4c` directly, with no recursive call). + Mechanism A's `enter_cell` is fully recursive. Grandchildren are therefore + refreshed only on a genuine cell-object change. +- Branch A writes only `objcell_id` (and the part array's copy), never the + child's `cell` pointer. It does not need to: the parent's `CObjCell` is + unchanged in that branch, and the children were already registered into it by + `enter_cell`. + +### The driving tick + +`CPhysics::UseTime @0x00509950` → `CPhysicsObj::update_object @0x005099e5` +(`@0x00515d10`) → `CPhysicsObj::UpdateObjectInternal @0x005156b0` → +`SetPositionInternal(this, transition) @0x00515914`. + +The decisive guard is the first thing `update_object` does: + +``` +00515d40 if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) { +00515eeb this->transient_state &= 0xffffff7f +00515ef5 return + } +``` + +**A parented object is never independently simulated.** Its cell — and its frame +— come exclusively from its parent's tick. This is the structural reason the +propagation in Mechanism A/B must exist and must be unconditional. + +--- + +## 2. What is a child's `objcell_id` while parented? + +**The parent's, maintained eagerly and redundantly. Not zero, not independent.** + +Write sites that set a child's `objcell_id` to the parent's value: + +| Site | Address | Value written | +|---|---|---| +| `enter_cell` recursion frame | `@0x00510f1e` | `arg2->m_DID.id` — the parent's `CObjCell` id | +| `SetPositionInternal` branch A loop | `@0x005153bd` | `arg2->sphere_path.curr_pos.objcell_id` — the parent's committed id | +| `set_cell_id_recursive` | `@0x00510db1` | `arg2`, recursed to children `@0x00510de3` | + +The child's copy is a genuine maintained mirror, not a derived read-through: +`CPhysicsObj` has exactly one `m_position` and the child's is written with the +parent's id at the sites above. + +**`set_cell_id_recursive @0x00510da0` is not part of the parenting path.** A +whole-file grep for callers returns exactly one, `@0x00506eba`, which is sky +object handling (`this->sky_obj.m_data[i]`). It is cited here only to close it +out as a candidate — it is not the mechanism. + +--- + +## 3. Where is a child's cell written? + +**In `change_cell`/`enter_cell` and in the `SetPositionInternal` commit — i.e. +the physics path. `set_parent` participates only by calling `change_cell`; it +contains no cell write of its own.** + +`CPhysicsObj::set_parent(CPhysicsObj*, uint32_t) @0x00515a90`, complete body: + +``` +00515a9d if (edi != 0 && add_child(edi, this, arg3) != 0) { +00515aba unset_parent(this) +00515ac1 leave_world(this) +00515ac6 this->parent = edi +00515ac9 cell = edi->cell +00515ad1 if (cell != 0) { +00515ad6 change_cell(this, cell) // <-- the only cell write, delegated +00515ae0 if (children_1 != 0 && CHILDLIST::FindChildIndex(children_1, this, &arg2) != 0) { +00515b0e UpdateChild(edi, this, children->part_numbers.data[ecx_4], &children->frames.data[ecx_4]) +00515b15 recalc_cross_cells(this) + } + } +00515b26 if (this->parent->state[1] & 0x40) { +00515b28 this->state |= 0x20 +00515b38 CPartArray::SetNoDrawInternal(part_array, 1) + } +00515b45 return 1 + } +00515b4d return 0 +``` + +The four-arg overload `set_parent(CPhysicsObj*, uint32_t, Frame const*) +@0x00515b50` is identical in shape: `unset_parent @0x00515b7e`, `leave_world +@0x00515b85`, `this->parent = arg2 @0x00515b8a`, `change_cell(this, cell) +@0x00515b9a`, `UpdateChild @0x00515ba4`, `recalc_cross_cells @0x00515bab`, plus +`m_bExaminationObject` propagation `@0x00515b7b`. + +Supporting facts: + +- `CPhysicsObj::add_child @0x0050f870` (and the 4-arg form `@0x0050f8f0`) is + pure list management — `CSetup::GetHoldingLocation @0x0050f896`, allocate + `CHILDLIST` `@0x0050f8aa`, `CHILDLIST::add_child @0x0050f8d1`. **No cell or + `objcell_id` write.** +- `CPhysicsObj::recalc_cross_cells @0x00515a30` recurses into children + `@0x00515a79` but only calls `calc_cross_cells @0x00515a5c` (shadow/cross-cell + registration) — it **reads** `this->m_position.objcell_id` as a guard + `@0x00515a3f` and never writes it. +- **Prior finding CONFIRMED, not refuted:** `CPhysicsObj::UpdateChild + @0x00512d50` composes a frame (`Frame::combine @0x00512d7d`) and calls + `set_frame(arg2, &var_40) @0x00512d8d`, then ticks particles/scripts. + `CPhysicsObj::set_frame @0x00514090` writes `m_position.frame` `@0x005140e9`, + `CPartArray::SetFrame @0x00514101`, and `UpdateChildrenInternal @0x00514108`. + **Neither touches `objcell_id` or `cell`.** The frame-only claim holds. + +Per-commit ordering inside `SetPositionInternal`, worth having explicit: +cell propagation (branch A loop, or branch B `change_cell` → recursive +`enter_cell`) **first** `@0x00515372`/`@0x00515385`, then `set_frame` +`@0x005153e0` → `UpdateChildrenInternal @0x00514108` → per-child `UpdateChild` +→ `set_frame(child)`. Cell before frame, every commit. + +--- + +## 4. What happens on `unset_parent`? + +**The child gets no cell back. `unset_parent` performs zero cell work.** + +`CPhysicsObj::unset_parent @0x00513470`, complete body: + +``` +00513473 parent = this->parent; if (parent == 0) return @0x00513478 +00513484 CHILDLIST::remove_child(parent->children, this) +00513495 if (this->parent->state[1] & 0x40) { +00513497 this->state &= 0xffffffdf +005134a7 CPartArray::SetNoDrawInternal(part_array, 0) + } +005134ac this->parent = nullptr +005134bf this->update_time = Timer::cur_time +005134ce return CPhysicsObj::clear_transient_states(this) +``` + +No `cell`, no `objcell_id`, no `leave_cell`. +`CPhysicsObj::clear_transient_states @0x00511bf0` was read in full — it touches +only `transient_state`, `calc_acceleration @0x00511bfa`/`@0x00511c2b`, and +`MovementManager::LeaveGround @0x00511c24`. **No cell write.** + +Consequently, immediately after `unset_parent` the child still holds the +parent's `objcell_id` **and** is still in that `CObjCell`'s object list. Every +call site resolves this itself, in one of two ways: + +| Call site | Address | Resolution | +|---|---|---| +| `CPhysicsObj::set_parent` (both overloads) | `@0x00515aba`, `@0x00515b7e` | followed by `leave_world @0x00515ac1`/`@0x00515b85`, then `change_cell` to the new parent's cell | +| `SmartBox::DoPickupEvent @0x00452240` | `@0x0045227f` | followed by `leave_world @0x00452286` — leaves the world | +| `SmartBox::HandleReceivedPosition @0x00453fd0` | `@0x00454129` | followed by `SetPlacementFrame @0x00454142` and `MoveOrTeleport @0x00454254` — server-driven re-placement assigns a cell | +| `CObjectMaint::DeleteObject @0x00508460` | `@0x005084b2` | `exit_world @0x0050846b` + `leave_world @0x00508472` ran **before**; then `unparent_children @0x005084b9`, then destructor | +| `CObjectMaint::DestroyObjects @0x00508c30` | `@0x00508dd7` | same pattern, then `unparent_children @0x00508dde`, destructor | +| `ParticleEmitter::Destroy @0x0051cdb0` | `@0x0051cdbd` | followed by `leave_world @0x0051cdc5` | + +`CPhysicsObj::leave_world @0x005155a0` is where a detaching child is actually +scrubbed: `remove_shadows_from_cells @0x005155dd`, `leave_cell(this, 0) +@0x005155e6` (which recurses children `@0x00510f84`), then +`this->m_position.objcell_id = 0 @0x005155f4` and `CPartArray::SetCellID(..., +0) @0x00515606`. + +**One precise gap, stated as such:** `leave_world` zeroes only **its own** +`objcell_id` `@0x005155f4`. Its recursive `leave_cell` clears each descendant's +`cell` pointer `@0x00510fa7` but leaves each descendant's `objcell_id` at its +stale value. So a grandchild of a world-leaving object retains a stale +`objcell_id` with a null `cell` until the next `enter_cell`. This is retail's +actual behavior, not an artifact of the decomp — the write at `@0x005155f4` is +plainly on `this` and the recursion at `@0x00510f84` plainly does not carry an +id-clearing write. + +--- + +## 5. Does retail have anything resembling acdream's per-render-tick child rebucket? + +**Retail has a per-commit child cell re-stamp, but it is on the physics path, +not a render path, and it is unconditional.** + +- **Name:** the child loop in `CPhysicsObj::SetPositionInternal(CPhysicsObj*, + CTransition const*) @0x00515330`, at `@0x0051539c`–`@0x005153d8`. +- **Cadence:** once per position commit, driven by `CPhysics::UseTime + @0x00509950` → `update_object @0x005099e5` → `UpdateObjectInternal + @0x005156b0` → `SetPositionInternal @0x00515914`. Same tick that runs + collision and movement — it is not tied to drawing, to pose composition, or to + mesh/part availability. +- **The comparable acdream code** is + `src/AcDream.App/Rendering/EquippedChildRenderController.cs:373` `TickChild`, + whose `_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId)` at + line 407 writes the same **value** retail writes (child cell := parent cell), + but reaches it only after `TryResolveExactAttachment`, + `TryGetRootPose`, `TryGetPartPoseSnapshot`, and + `EquippedChildAttachment.TryComposePoseInto` all succeed, and only after + `ApplyParentWorldPose` returns true. Retail's equivalent write has no such + predicates. + +So retail's cell propagation is **gated on nothing except `part_array != 0`** +(Mechanism A) or **nothing at all** (Mechanism B), whereas acdream's is gated on +successful render-side pose composition. That gating is the divergence, and it +is exactly why headless — which never constructs +`EquippedChildRenderController` — leaves parented children cell-less forever. + +--- + +## Direct consequence for route 7 + +The child cell write **belongs in Runtime**, and specifically it belongs in +*both* the `set_parent` analog *and* the position-commit analog — retail writes +it in both places, and `set_parent` is the lesser of the two. + +Three consequences follow directly from the citations above: + +1. **`set_parent` alone is insufficient.** Retail's `set_parent` performs no + cell write of its own; it delegates one-shot placement to `change_cell + @0x00515ad6`. The *sustaining* write — the one that keeps a child correct as + its parent walks around — is `SetPositionInternal`'s child loop + `@0x005153bd` and the recursive `enter_cell` `@0x00510f1e`, both on the + physics commit. A Runtime `set_parent` analog that writes the cell once at + attach time will be correct at attach and stale on the first parent cell + crossing thereafter. + +2. **The write is unconditional in retail and must be unconditional in + Runtime.** Because `update_object @0x00515d10` early-returns on `parent != 0` + `@0x00515d40`, a parented child has no tick of its own; the parent's commit + is its only source of truth. Predicating the write on pose/mesh readiness — + as `TickChild` does — has no retail counterpart. + +3. **App's render-tick rebucket can be safely demoted**, because it writes a + value Runtime already has at commit time (the parent's committed + `objcell_id`) and needs none of the pose data it currently waits for. Its + sole retail-justified residue is presentation + (`child.Entity.ParentCellId`/`PublishChildPose`), not membership. + +**One-line verdict:** route 7 can safely demote App's render-tick rebucket to +presentation-only, provided the Runtime replacement writes the child's cell at +*both* the `set_parent` analog and the per-commit position analog — retail does +both, and the per-commit one is the load-bearing half. + +--- + +## NOT ESTABLISHED + +Nothing in the five questions is unresolved. Two adjacent facts are deliberately +not claimed: + +- **When branch A of `SetPositionInternal` can observe `curr_pos.objcell_id != + this->cell->m_DID.id`** (i.e. when the same `CObjCell` legitimately carries a + changing `objcell_id`) is not established. It does not affect the answer — + retail writes the child's id there unconditionally regardless — but if it + matters later, the settling read is `CTransition`/`CSphere` `curr_cell` + maintenance in `SPHEREPATH::set_curr_cell` plus outdoor `CObjCell` id + assignment, or a cdb breakpoint on `CPhysicsObj::SetPositionInternal + @0x00515330` logging `@ecx->cell->m_DID.id` against + `arg2->sphere_path.curr_pos.objcell_id`. +- **Whether any parented child in practice has children of its own** (the case + where branch A's depth-1 loop would leave a grandchild stale) is not + established from the decomp. Settling it is a live-data question, not a code + question: a cdb breakpoint on `CPhysicsObj::set_parent @0x00515a90` logging + `@ecx->children` non-null at attach time would answer it.