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 <noreply@anthropic.com>
18 KiB
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<unsigned long> (acclient.h:30149), HashBaseData<unsigned long> = { 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<CShadowObj>(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–@0x005153d8readsthis->childrenand writes+0x4cdirectly, with no recursive call). Mechanism A'senter_cellis 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'scellpointer. It does not need to: the parent'sCObjCellis unchanged in that branch, and the children were already registered into it byenter_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, allocateCHILDLIST@0x0050f8aa,CHILDLIST::add_child @0x0050f8d1. No cell orobjcell_idwrite.CPhysicsObj::recalc_cross_cells @0x00515a30recurses into children@0x00515a79but only callscalc_cross_cells @0x00515a5c(shadow/cross-cell registration) — it readsthis->m_position.objcell_idas a guard@0x00515a3fand never writes it.- Prior finding CONFIRMED, not refuted:
CPhysicsObj::UpdateChild @0x00512d50composes a frame (Frame::combine @0x00512d7d) and callsset_frame(arg2, &var_40) @0x00512d8d, then ticks particles/scripts.CPhysicsObj::set_frame @0x00514090writesm_position.frame@0x005140e9,CPartArray::SetFrame @0x00514101, andUpdateChildrenInternal @0x00514108. Neither touchesobjcell_idorcell. 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:373TickChild, whose_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId)at line 407 writes the same value retail writes (child cell := parent cell), but reaches it only afterTryResolveExactAttachment,TryGetRootPose,TryGetPartPoseSnapshot, andEquippedChildAttachment.TryComposePoseIntoall succeed, and only afterApplyParentWorldPosereturns 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:
-
set_parentalone is insufficient. Retail'sset_parentperforms no cell write of its own; it delegates one-shot placement tochange_cell @0x00515ad6. The sustaining write — the one that keeps a child correct as its parent walks around — isSetPositionInternal's child loop@0x005153bdand the recursiveenter_cell@0x00510f1e, both on the physics commit. A Runtimeset_parentanalog that writes the cell once at attach time will be correct at attach and stale on the first parent cell crossing thereafter. -
The write is unconditional in retail and must be unconditional in Runtime. Because
update_object @0x00515d10early-returns onparent != 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 — asTickChilddoes — has no retail counterpart. -
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
SetPositionInternalcan observecurr_pos.objcell_id != this->cell->m_DID.id(i.e. when the sameCObjCelllegitimately carries a changingobjcell_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 isCTransition/CSpherecurr_cellmaintenance inSPHEREPATH::set_curr_cellplus outdoorCObjCellid assignment, or a cdb breakpoint onCPhysicsObj::SetPositionInternal @0x00515330logging@ecx->cell->m_DID.idagainstarg2->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 @0x00515a90logging@ecx->childrennon-null at attach time would answer it.