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>
381 lines
18 KiB
Markdown
381 lines
18 KiB
Markdown
# 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`–`@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.
|