Two read-only research landings that unblock the last two C4 routes. **Parent-cell propagation (unblocks route 7).** Retail DOES re-cell children when the parent crosses a cell, recursively to unbounded depth. SetPositionInternal @0x00515330 branches on `this->cell == curr_cell` @0x0051536d; the changed branch reaches change_cell @0x00513390, whose delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over children and write the FULL identity (add_object @0x00510ee2, objcell_id @0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35). change_cell itself has no child loop — the recursion is in the delegates. The clincher: update_object @0x00515d10 early-returns on `parent != 0` @0x00515d40, so a child never runs its own physics tick and parent propagation is the ONLY mechanism maintaining its cell. The trap this retires: the depth-1 loop @0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array id only, deliberately not the cell pointer), not the propagation. An implementer finding it first would conclude "depth-1, id-only" and ship equipped items stranded at landblock boundaries — the #184 class. Route 7's planned set_parent-only write would have done exactly that. Settled by READING, not by a debugger trace. The scoping had listed this as needing live cdb evidence, but change_cell/set_cell's child handling had simply never been read; the project's grep -> decompile -> verify order had not been exhausted. One BN field-name gap was closed by walking struct CPhysicsObj in the verbatim acclient.h, so no PE byte-decode was needed either. A breakpoint set is recorded for optional confirmation only. **Route 3 scoping (portal, the last route).** ~225-400 added non-comment production lines, ONE slice, contingent on #280 splitting out. Portalling works today; route 3 removes a duplicate authority (LocalPlayerTeleportPlacement.Place), it does not fix a bug. Eight dated-inventory claims are now false, the most consequential being "the binding machinery is 100% dormant end to end" — the portal authority's CONSUMPTION and validation side is live production code at three layers and is exercised by every placement; only the PRODUCER adapter is missing. That makes route 3 materially smaller than the campaign plan implies. #280 SPLITS from route 3, definitively: it is a reveal-gate/prefetch-window concern (WorldRevealReadinessBarrier's neighbourhood radius versus retail's mid_radius, LScape::PreFetchCells @0x00505660 / SmartBox::SetRegion @0x00453227), mechanically disjoint from the placement cutover — route 3 reads the ready predicate, #280 rewrites it. The campaign plan already sequences #280 separately; only the session handoff said it "rides with" route 3, and the plan is right. Retail's local portal arrival is the GENERIC path for the third route running: SmartBox::TeleportPlayer @0x00453910 is SetPositionSimple(player, dest, 1) with flags 0x1012 — route 2's exact primitive — plus PlayerPositionUpdated. Two rule inversions recorded so route 3's implementer cannot carry the wrong rule forward from the routes just landed: route 2's "never re-arm the leash" INVERTS here (the teleport branch arms ConstrainTo @0x0045418A and zeroes velocity @0x004541B4), and 4b-3's hook-before-placement ordering INVERTS (the local teleport_hook runs AFTER placement, from PlayerPositionUpdated @0x004538AE). The classifier's dormant LocalPlayer-teleport route already encodes both. Two documentation defects found in passing and recorded, not fixed: the 2026-07-16 portal pseudocode attributes portal arrival to enter_world (that is the login path), and a stale comment hides a live second writer — the generic wire-pose write does run for the local player (AP-131/C5 scope). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
520 lines
25 KiB
Markdown
520 lines
25 KiB
Markdown
# Retail: does a parent's cell crossing propagate to its children?
|
||
|
||
**Date:** 2026-08-04
|
||
**Worktree:** `peaceful-visvesvaraya-e0a196`, HEAD `cff52c44`
|
||
**Mode:** read-only retail research. No production or test code written. Nothing committed.
|
||
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build,
|
||
PDB-named) + `docs/research/named-retail/acclient.h` (verbatim retail struct definitions).
|
||
**Consumer:** C4 route 7 contract — demoting `EquippedChildRenderController.TickChild`
|
||
to presentation-only and moving the authoritative child-cell write into Runtime's
|
||
`TryCommitParent` / `CommitAcceptedParentCellless`.
|
||
|
||
---
|
||
|
||
## VERDICT
|
||
|
||
**YES. Retail propagates a parent's cell to its children, recursively, to unbounded
|
||
depth, on every parent cell crossing — and additionally refreshes each direct child's
|
||
`objcell_id` on every physics tick in which the parent moves within its current cell.**
|
||
|
||
A `set_parent`-only cell write is **NOT** retail-faithful. It is correct at attach and
|
||
stale from the parent's first cell crossing onward. Route 7's contract as originally
|
||
scoped would reintroduce the #184 invisible-but-solid class exactly as feared.
|
||
|
||
The good news for route 7: the propagation is driven by the **physics position commit**
|
||
(`CPhysicsObj::SetPositionInternal` @`0x00515330`), *not* by anything render-side. So
|
||
moving the authoritative write into Runtime is the right direction — the contract just
|
||
has to be *"parent cell change propagates to children"*, not *"set_parent writes once"*.
|
||
|
||
**No cdb trace is required.** The static read is unambiguous, and the struct-offset
|
||
arithmetic independently corroborates the one place Binary Ninja lost field names.
|
||
See §7 for why, and §8 for the (unnecessary) breakpoint set if the user wants
|
||
belt-and-braces confirmation anyway.
|
||
|
||
---
|
||
|
||
## 1. The gap named by the scoping doc, now closed
|
||
|
||
`docs/research/2026-08-04-c4-routes-6-7-scoping.md` §7.4 / §7.8 T5 stated that
|
||
`CPhysicsObj::change_cell` and `CPhysicsObj::set_cell`'s own child handling
|
||
"were not read". Reading them is the whole answer.
|
||
|
||
Two corrections to the framing up front:
|
||
|
||
1. There is **no `CPhysicsObj::set_cell`** in the 2013 build. The functions that exist
|
||
are `CPhysicsObj::set_cell_id` @`0x0050f4f0`,
|
||
`CPhysicsObj::set_cell_id_recursive` @`0x00510da0`, and
|
||
`CPhysicsObj::change_cell` @`0x00513390`. I searched the full 1,437,645-line
|
||
pseudo-C; `set_cell` as a symbol does not appear.
|
||
2. `change_cell` itself contains **no child loop**. It delegates entirely — and the
|
||
delegates (`leave_cell`, `enter_cell`) are where the recursion lives. That is why
|
||
a reader skimming `change_cell` alone would conclude "no propagation", which is
|
||
the trap this doc exists to close.
|
||
|
||
---
|
||
|
||
## 2. `change_cell` @`0x00513390` — the dispatcher
|
||
|
||
Read verbatim:
|
||
|
||
```
|
||
00513390 void __thiscall CPhysicsObj::change_cell(class CPhysicsObj* this, class CObjCell* arg2)
|
||
0051339b if (this->cell != 0)
|
||
0051339f CPhysicsObj::leave_cell(this, 1);
|
||
005133aa if (arg2 != 0)
|
||
005133af CPhysicsObj::enter_cell(this, arg2);
|
||
005133b5 return;
|
||
005133c1 this->m_position.objcell_id = 0;
|
||
005133c8 if ((state & 0x1000) == 0)
|
||
005133d3 CPartArray::SetCellID(part_array, 0);
|
||
005133d8 this->cell = nullptr;
|
||
```
|
||
|
||
- @`0x0051339f` — unconditional (given a non-null current cell) `leave_cell`.
|
||
- @`0x005133af` — unconditional (given a non-null target) `enter_cell`, then **early
|
||
return** @`0x005133b5`. The tail from @`0x005133c1` is the *removal-only* path
|
||
(`arg2 == 0`).
|
||
|
||
Both delegates recurse into `children`. That is the propagation.
|
||
|
||
**Asymmetry worth recording:** on the `arg2 == 0` (removal) path, only `this`'s
|
||
`objcell_id` is zeroed @`0x005133c1`. `leave_cell` nulls each *child's* `cell` pointer
|
||
but never touches a child's `objcell_id` (§4). So after a removal, children are left
|
||
with `cell == nullptr` and a **stale non-zero `objcell_id`**. This is retail behavior,
|
||
observed not inferred; it matters if acdream ever treats `objcell_id != 0` as a
|
||
liveness predicate for children.
|
||
|
||
---
|
||
|
||
## 3. `enter_cell` @`0x00510ed0` — the recursion, and what it writes
|
||
|
||
Read verbatim:
|
||
|
||
```
|
||
00510ed0 void __thiscall CPhysicsObj::enter_cell(class CPhysicsObj* this, class CObjCell* arg2)
|
||
00510ed8 if (this->part_array != 0)
|
||
00510ee2 CObjCell::add_object(arg2, this);
|
||
00510ee7 class CHILDLIST* children = this->children;
|
||
00510eec if (children != 0)
|
||
00510ef4 if (children->num_objects > 0)
|
||
00510f0f do
|
||
00510f03 CPhysicsObj::enter_cell(this->children->objects.data[edi_1], arg2);
|
||
00510f0b edi_1 += 1;
|
||
00510f0f while (edi_1 < this->children->num_objects);
|
||
00510f1b uint32_t id = arg2->m_DID.id;
|
||
00510f1e this->m_position.objcell_id = id;
|
||
00510f21 if ((state & 0x1000) == 0)
|
||
00510f2b CPartArray::SetCellID(part_array, id);
|
||
00510f35 this->cell = arg2;
|
||
00510f3e CPartArray::AddLightsToCell(part_array_1, arg2);
|
||
```
|
||
|
||
Answering the task's question 3 directly — **what does it recurse over?**
|
||
`this->children->objects.data[i]` for `i` in `[0, children->num_objects)`
|
||
@`0x00510f03`. It is **self-recursive**, so the recursion is **unbounded depth**, not
|
||
depth-1: a child's own children are reached too.
|
||
|
||
**What each recursion level writes** (i.e. what every child in the subtree gets):
|
||
|
||
| Address | Write | Effect on the child |
|
||
|---|---|---|
|
||
| `0x00510ee2` | `CObjCell::add_object(arg2, child)` | child joins the new cell's object list |
|
||
| `0x00510f1e` | `child->m_position.objcell_id = arg2->m_DID.id` | **canonical cell id** |
|
||
| `0x00510f2b` | `CPartArray::SetCellID(child->part_array, id)` | render/part-array cell id |
|
||
| `0x00510f35` | `child->cell = arg2` | **canonical cell pointer** |
|
||
| `0x00510f3e` | `CPartArray::AddLightsToCell(child->part_array, arg2)` | lights re-registered |
|
||
|
||
So a child receives the **complete** cell identity — pointer, id, cell-list membership,
|
||
and lights — identical to what the parent receives. Every child ends up in the *same*
|
||
`CObjCell` as the parent (`arg2` is passed down unchanged @`0x00510f03`).
|
||
|
||
**Guard, load-bearing:** @`0x00510ed8` the entire body is gated on
|
||
`this->part_array != 0`. A child with a null part array receives **nothing** — no cell,
|
||
no `objcell_id`, no membership. Recursion also stops there, so that child's own
|
||
subtree is skipped.
|
||
|
||
---
|
||
|
||
## 4. `leave_cell` @`0x00510f50` — the matching recursive teardown
|
||
|
||
```
|
||
00510f50 void __thiscall CPhysicsObj::leave_cell(class CPhysicsObj* this, int32_t arg2)
|
||
00510f53 class CObjCell* cell = this->cell;
|
||
00510f5b if (cell != 0)
|
||
00510f5e CObjCell::remove_object(cell, this);
|
||
00510f63 class CHILDLIST* children = this->children;
|
||
00510f68 if (children != 0)
|
||
00510f70 if (children->num_objects > 0)
|
||
00510f90 do
|
||
00510f84 CPhysicsObj::leave_cell(this->children->objects.data[edi_1], arg2);
|
||
00510f8c edi_1 += 1;
|
||
00510f90 while (edi_1 < this->children->num_objects);
|
||
00510f94 class CPartArray* part_array = this->part_array;
|
||
00510fa2 CPartArray::RemoveLightsFromCell(part_array, this->cell);
|
||
00510fa7 this->cell = nullptr;
|
||
```
|
||
|
||
Also self-recursive @`0x00510f84`, also unbounded depth. Per child:
|
||
`CObjCell::remove_object` @`0x00510f5e`, `RemoveLightsFromCell` @`0x00510fa2`,
|
||
`cell = nullptr` @`0x00510fa7`.
|
||
|
||
**Note what is absent:** `leave_cell` never writes `objcell_id`. That is the source of
|
||
the §2 asymmetry. `arg2` (the `1` passed from `change_cell` @`0x0051339f`) is threaded
|
||
through the recursion @`0x00510f84` but is **never read** in the body — dead in this
|
||
build.
|
||
|
||
**Guard:** @`0x00510f5b` gated on `this->cell != 0`, evaluated per recursion level. A
|
||
child already cell-less is skipped along with its subtree.
|
||
|
||
---
|
||
|
||
## 5. The depth-1 child loop in `SetPositionInternal` @`0x0051539c`–@`0x005153d8`
|
||
|
||
Answering the task's question 2. First, the containing function's identity:
|
||
@`0x00515330` is
|
||
`int32_t __thiscall CPhysicsObj::SetPositionInternal(class CPhysicsObj* this, class CTransition const* arg2)`
|
||
— the **two-argument overload**, i.e. the post-transition position commit. (Distinct
|
||
from the four-arg `SetPositionInternal` @`0x00515bd0`, which calls into it
|
||
@`0x00515c94`.)
|
||
|
||
The relevant branch:
|
||
|
||
```
|
||
0051534a class CObjCell* curr_cell = arg2->sphere_path.curr_cell;
|
||
00515360 if (curr_cell == 0) // → lost-cell path
|
||
0051536d if (this->cell == curr_cell) // SAME-CELL branch
|
||
00515385 this->m_position.objcell_id = objcell_id;
|
||
00515392 CPartArray::SetCellID(part_array, objcell_id);
|
||
0051539c if (children != 0)
|
||
005153a3 if (children->num_objects > 0)
|
||
005153d8 do
|
||
005153ae void* eax_2 = this->children->objects.data[ebx_1];
|
||
005153b7 cond:4_1 = (*(child + 0xa8) & 0x1000) != 0;
|
||
005153ba objcell_id_1 = arg2->sphere_path.curr_pos.objcell_id;
|
||
005153bd *(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1;
|
||
005153c0 if (!cond:4_1)
|
||
005153cc CPartArray::SetCellID(*(char*)eax_2 + 0x10, objcell_id_1);
|
||
005153d4 ebx_1 += 1;
|
||
005153d8 while (ebx_1 < this->children->num_objects);
|
||
0051536d else
|
||
00515372 CPhysicsObj::change_cell(this, curr_cell); // CELL-CHANGE branch
|
||
005153e0 CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame);
|
||
```
|
||
|
||
**Binary Ninja lost the field names here** (it typed the loop variable as `void*`), so
|
||
the writes appear as raw offsets. Resolving them from the verbatim header
|
||
`docs/research/named-retail/acclient.h` — this is **arithmetic, not inference**:
|
||
|
||
`struct CPhysicsObj : LongHashData` member walk, anchored on the fact that BN itself
|
||
names offset `0x10` as `part_array` in the sibling functions
|
||
(`set_cell_id_recursive` @`0x00510da0` etc.):
|
||
|
||
| Offset | Member |
|
||
|---|---|
|
||
| `0x10` | `CPartArray *part_array` |
|
||
| `0x14`–`0x1C` | `AC1Legacy::Vector3 player_vector` |
|
||
| `0x20` | `float player_distance` |
|
||
| `0x24` | `float CYpt` |
|
||
| `0x28` | `CSoundTable *sound_table` |
|
||
| `0x2C` | `bool m_bExaminationObject` (align 4) |
|
||
| `0x30` | `ScriptManager *script_manager` |
|
||
| `0x34` | `PhysicsScriptTable *physics_script_table` |
|
||
| `0x38` | `PScriptType default_script` |
|
||
| `0x3C` | `float default_script_intensity` |
|
||
| `0x40` | `CPhysicsObj *parent` |
|
||
| `0x44` | `CHILDLIST *children` |
|
||
| `0x48` | `Position m_position` → `PackObj` vtable ptr |
|
||
| **`0x4C`** | **`m_position.objcell_id`** |
|
||
| `0x50`–`0x8C` | `m_position.frame` (`qw..qz`, `m_fl2gv[9]`, `m_fOrigin`) |
|
||
| `0x90` | `CObjCell *cell` |
|
||
| `0x94` | `unsigned int num_shadow_objects` |
|
||
| `0x98`–`0xA4` | `DArray<CShadowObj> shadow_objects` (4 dwords) |
|
||
| **`0xA8`** | **`unsigned int state`** |
|
||
|
||
The walk lands exactly on `0x4C = m_position.objcell_id`, `0x10 = part_array`, and
|
||
`0xA8 = state` — all three offsets used by the loop, all three consistent. There is no
|
||
residual ambiguity and **no PE byte-decode is needed** for this site.
|
||
|
||
**So what does the depth-1 loop write?** Per direct child:
|
||
|
||
- `child->m_position.objcell_id = curr_pos.objcell_id` @`0x005153bd`
|
||
- `CPartArray::SetCellID(child->part_array, objcell_id)` @`0x005153cc`, gated on the
|
||
**child's own** `state & 0x1000` @`0x005153b7`
|
||
|
||
**Cell id only — NOT the `cell` pointer** (`0x90` is never written here), and **NOT
|
||
recursive** (children-of-children are not visited in this branch).
|
||
|
||
That is coherent, not a bug: this branch is entered precisely when
|
||
`this->cell == curr_cell` @`0x0051536d`, i.e. the parent did **not** change cell — so
|
||
every child's `cell` pointer is already correct and needs no write. The loop is a
|
||
cheap per-tick id refresh, not a re-cell.
|
||
|
||
**Therefore the depth-1 loop is *not* the answer to the route-7 question.** It is the
|
||
same-cell fast path. The answer is the `else` @`0x00515372`.
|
||
|
||
---
|
||
|
||
## 6. Cadence — when each path actually runs
|
||
|
||
`CPhysicsObj::UpdateObjectInternal` @`0x005156b0` is the per-tick physics update. It
|
||
runs the transition and commits:
|
||
|
||
```
|
||
005158b2 class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0);
|
||
005158bb if (eax_10 == 0)
|
||
00515937 CPhysicsObj::set_frame(this, &var_40); // blocked → frame only
|
||
005158bb else
|
||
00515914 CPhysicsObj::SetPositionInternal(this, eax_10); // moved → commit
|
||
```
|
||
|
||
So `SetPositionInternal` @`0x00515330` runs **every physics tick in which the object
|
||
successfully moves** (@`0x00515914`). Inside it the branch @`0x0051536d` selects:
|
||
|
||
| Parent's tick | Branch | Children get |
|
||
|---|---|---|
|
||
| Moved, same cell | @`0x0051536d` same-cell | depth-1 `objcell_id` + part-array id refresh (@`0x005153bd`, @`0x005153cc`) |
|
||
| **Moved, crossed a cell** | @`0x00515372` → `change_cell` | **full recursive re-cell: `remove_object` / `add_object`, `objcell_id`, `cell` pointer, part-array id, lights** |
|
||
| Blocked (`transition` returned 0) | @`0x00515937` `set_frame` | frame only — no cell work needed, parent didn't move |
|
||
| `curr_cell == 0` | @`0x00515360` lost-cell | `GotoLostCell` @`0x00515579`; no child cell work |
|
||
|
||
Other entry points that reach the same recursive propagation:
|
||
|
||
- `CPhysicsObj::ForceIntoCell` @`0x00515660` → `change_cell` @`0x00515684`, guarded by
|
||
`this->cell != arg2` @`0x0051567f`. (Teleport / corpse forcing — reached from
|
||
@`0x00515c61`.)
|
||
- `CPhysicsObj::AddObjectToSingleCell` @`0x005149e0` → `change_cell` @`0x005149ff`.
|
||
- `CPhysicsObj::add_obj_to_cell` @`0x005159e0` → `enter_cell` @`0x005159e9` directly
|
||
(then `UpdateChildrenInternal` @`0x00515a17`, `calc_cross_cells_static` @`0x00515a1e`).
|
||
- `CPhysicsObj::set_parent` @`0x00515a90` → `change_cell` @`0x00515ad6` (the
|
||
already-established attach-time write), and the 4-arg overload @`0x00515b50` →
|
||
`change_cell` @`0x00515b9a`.
|
||
- 4-arg `SetPositionInternal` @`0x00515bd0` → 2-arg @`0x00515c94`.
|
||
|
||
### 6.1 The clincher: a child never self-updates
|
||
|
||
`CPhysicsObj::update_object` @`0x00515d10` opens with:
|
||
|
||
```
|
||
00515d40 if ((this->parent != 0 || (this->cell == 0 || (this->state & 0x1000000) != 0)))
|
||
00515eeb this->transient_state &= 0xffffff7f;
|
||
00515ef5 return;
|
||
```
|
||
|
||
**`parent != 0` → immediate return.** A parented object is excluded from its own physics
|
||
tick entirely. It never calls `transition`, never calls `SetPositionInternal`, never
|
||
touches its own cell.
|
||
|
||
This is the structural proof that closes the question: since a child *cannot* update its
|
||
own cell, and children demonstrably do end up in the right cell in retail, **parent
|
||
propagation is the only mechanism that exists.** If `enter_cell`'s recursion did not
|
||
write the child's cell, an equipped weapon would be permanently stranded in the cell
|
||
where it was equipped — which is precisely the #184 symptom, and is not what retail
|
||
does.
|
||
|
||
---
|
||
|
||
## 7. Read vs. inferred — explicit ledger
|
||
|
||
Per the standards in the task, separating what the source says from what I concluded.
|
||
|
||
**Read directly from the pseudo-C (verbatim, cited):**
|
||
|
||
- `change_cell` delegates to `leave_cell` / `enter_cell` (@`0x0051339f`, @`0x005133af`).
|
||
- `enter_cell` recurses over `children->objects.data[i]` (@`0x00510f03`) and writes
|
||
`objcell_id` (@`0x00510f1e`), part-array cell id (@`0x00510f2b`), `cell` pointer
|
||
(@`0x00510f35`), and `CObjCell::add_object` (@`0x00510ee2`).
|
||
- `leave_cell` recurses (@`0x00510f84`) and writes `cell = nullptr` (@`0x00510fa7`) +
|
||
`remove_object` (@`0x00510f5e`).
|
||
- Both recursions are self-calls → unbounded depth.
|
||
- `SetPositionInternal` @`0x00515330` branches on `this->cell == curr_cell`
|
||
(@`0x0051536d`); `else` → `change_cell` (@`0x00515372`).
|
||
- The depth-1 loop (@`0x0051539c`–@`0x005153d8`) writes child `+0x4c` and child
|
||
`+0x10`'s cell id only.
|
||
- `UpdateObjectInternal` calls `SetPositionInternal` per moving tick (@`0x00515914`).
|
||
- `update_object` early-returns on `parent != 0` (@`0x00515d40`).
|
||
- `references/` in this worktree contains **only `WorldBuilder`**. ACE is **not
|
||
present** — see §9.
|
||
|
||
**Resolved by arithmetic, not guessed:** child `+0x4c` = `m_position.objcell_id`,
|
||
`+0x10` = `part_array`, `+0xa8` = `state`. Derived by walking `struct CPhysicsObj` in
|
||
`acclient.h` (§5 table), independently anchored on BN's own naming of `0x10` as
|
||
`part_array` in sibling functions. I regard this as read, not inferred.
|
||
|
||
**Inferred (flagged as such):**
|
||
|
||
- That the §2 removal-path asymmetry (child keeps a stale `objcell_id` while `cell` goes
|
||
null) is *intentional* rather than a latent retail bug. The code plainly does it;
|
||
the intent is my reading. It does not affect the verdict.
|
||
- That `state & 0x1000` is a "suppress part-array sync" flag. The bit is checked
|
||
identically at @`0x005153b7`, @`0x00510f21`, @`0x005133c8`, @`0x005140f7`,
|
||
@`0x00510db4`; I did not chase its symbolic name because the verdict does not
|
||
depend on it.
|
||
- That `leave_cell`'s `arg2` being unread is dead-parameter residue rather than
|
||
something BN elided. It is absent from the decompiled body; a byte-decode could
|
||
confirm, but nothing hinges on it.
|
||
|
||
**Binary Ninja elisions encountered:** exactly one site of consequence — the `void*`
|
||
typing in the §5 loop, fully resolved by the header walk. Elsewhere the x87 comparison
|
||
idioms are mangled (e.g. @`0x00515473`) but sit in the contact-plane / walkable code,
|
||
not on the child-cell path. **No PE byte-decode against
|
||
`C:\Users\erikn\Downloads\acclient.exe` + `refs/acclient.pdb` is required for this
|
||
question.**
|
||
|
||
---
|
||
|
||
## 8. Secondary observation: cross-cells / shadow lists are *not* refreshed per move
|
||
|
||
Worth recording because it is an adjacent trap, and because it distinguishes two things
|
||
route 7 might otherwise conflate.
|
||
|
||
`CPhysicsObj::recalc_cross_cells` @`0x00515a30` **does** recurse over children
|
||
@`0x00515a79`. But on the movement path, `SetPositionInternal`'s tail calls only the
|
||
**non-recursive** forms:
|
||
|
||
```
|
||
0051550b if (this->cell != 0)
|
||
00515517 if ((this->state & 0x10000) != 0)
|
||
0051551b CPhysicsObj::calc_cross_cells(this); // this only
|
||
0051552b return 1;
|
||
0051553a if (arg2->cell_array.num_cells > 0)
|
||
0051553e CPhysicsObj::remove_shadows_from_cells(this);
|
||
0051554c CPhysicsObj::add_shadows_to_cells(this, &arg2->cell_array);
|
||
```
|
||
|
||
`recalc_cross_cells` is reached only at attach (`set_parent` @`0x00515b15`,
|
||
@`0x00515bab`) and via `calc_cross_cells_static` @`0x00515a1e` in `add_obj_to_cell`.
|
||
|
||
**Meaning:** a child's *canonical* cell (pointer + id + `CObjCell` membership) **is**
|
||
maintained across parent movement; its *cross-cell / shadow* registration is **not**
|
||
re-derived per parent tick. If acdream's child handling has a shadow-cell analogue,
|
||
matching retail means propagating the canonical cell but **not** rebuilding child
|
||
shadow lists every tick.
|
||
|
||
There is also a third, movement-unrelated recursive helper:
|
||
`CPhysicsObj::set_cell_id_recursive` @`0x00510da0` — recurses @`0x00510de3`, writes
|
||
`objcell_id` @`0x00510db1` + part-array id @`0x00510dbe`, but **not** the `cell`
|
||
pointer. Callers are the sky-object path @`0x00506eba` and `CObjectMaint::GotoLostCell`
|
||
@`0x00508210` (via `set_cell_id` @`0x00508226`). Not on the equipped-child path;
|
||
listed so it isn't mistaken for the propagation mechanism.
|
||
|
||
---
|
||
|
||
## 9. ACE cross-check — NOT PERFORMED, reference absent
|
||
|
||
The task asked for an ACE cross-check. **`references/` in this worktree contains only
|
||
`WorldBuilder`.** ACE is not vendored here (`find` over the worktree returns only
|
||
`docs/reference/ace-commands.md`, a command catalog, not source).
|
||
|
||
Per the task's own instruction, I am saying so rather than guessing. I have made **no
|
||
claim** about what `ACE.Server/Physics/` does with `change_cell` / `enter_cell` child
|
||
handling. If a cross-check is wanted, it needs a worktree with ACE present, or a run
|
||
from the main repo; the ACE files to read would be
|
||
`Source/ACE.Server/Physics/PhysicsObj.cs` (`change_cell`, `enter_cell`, `leave_cell`,
|
||
`set_parent`, `SetPositionInternal`) and `Source/ACE.Server/Physics/Common/ObjCell.cs`.
|
||
|
||
Note that ACE is in any case only an interpretation aid here — per CLAUDE.md's
|
||
workflow, the decompiled retail code is ground truth and wins any disagreement. The
|
||
retail read above is unambiguous, so an ACE disagreement would not change the verdict;
|
||
it would only be interesting as a note about ACE.
|
||
|
||
---
|
||
|
||
## 10. Consequences for route 7's contract
|
||
|
||
**The contract can be pinned now, without a cdb trace.** What it must say:
|
||
|
||
1. **`set_parent`-time write is necessary but not sufficient.** Retail writes the
|
||
child's cell at attach (`set_parent` @`0x00515ad6`) **and** at every subsequent
|
||
parent cell crossing (`SetPositionInternal` @`0x00515372` → `change_cell`). Route 7
|
||
must implement both.
|
||
|
||
2. **The authoritative write belongs on the parent's physics-commit path, not a render
|
||
tick.** Retail's trigger is `SetPositionInternal` @`0x00515330`, reached from
|
||
`UpdateObjectInternal` @`0x00515914`. So demoting
|
||
`EquippedChildRenderController.TickChild` to presentation-only is **directionally
|
||
correct** — the render tick was never retail's owner of this write. But the
|
||
authoritative write must land in `TryCommitParent` /
|
||
`CommitAcceptedParentCellless` **as a cell-change propagation step**, not as a
|
||
one-shot at attach.
|
||
|
||
3. **Propagation is recursive to unbounded depth**, not depth-1
|
||
(`enter_cell` @`0x00510f03`, `leave_cell` @`0x00510f84`). If acdream can ever nest
|
||
children (child-of-child), the propagation must recurse. If acdream's model is
|
||
structurally depth-1 for equipped items, a depth-1 implementation is
|
||
behaviorally equivalent — but that equivalence should be stated as an explicit
|
||
assumption in the contract, with a register row if it is load-bearing.
|
||
|
||
4. **The child's write is the full cell identity**, not just the id: `cell` pointer
|
||
(@`0x00510f35`), `objcell_id` (@`0x00510f1e`), cell-list membership
|
||
(@`0x00510ee2` / @`0x00510f5e`), part-array cell id (@`0x00510f2b`). A partial write
|
||
(id without membership) would leave the #184 class only half-closed.
|
||
|
||
5. **Same-cell ticks still refresh child `objcell_id`** (@`0x005153bd`) but must **not**
|
||
rewrite the child's `cell` pointer. Retail deliberately splits these.
|
||
|
||
6. **Do not rebuild child cross-cell/shadow lists per parent tick** (§8) — retail
|
||
doesn't, and doing so would be a performance divergence with no faithfulness gain.
|
||
|
||
7. **Children are excluded from their own physics tick** (`update_object` @`0x00515d10`,
|
||
guard @`0x00515d40`). If acdream ever ticks an equipped child through the ordinary
|
||
physics path, that is itself a divergence independent of this question.
|
||
|
||
**Register note:** if route 7 ships a depth-1-only propagation (item 3) or omits the
|
||
same-cell `objcell_id` refresh (item 5), each is a deviation and needs its row in
|
||
`docs/architecture/retail-divergence-register.md` in the same commit, per the workflow
|
||
rules.
|
||
|
||
---
|
||
|
||
## 11. cdb breakpoints — NOT needed, recorded only for completeness
|
||
|
||
The task asked for the breakpoint set *only if reading truly cannot answer it*. Reading
|
||
answers it. I am recording the set anyway so nobody has to re-derive it if the user
|
||
wants independent confirmation before pinning a contract this load-bearing.
|
||
|
||
Per `memory/reference_retail_debugger.md`: `qd` inside a `bp` action is forbidden;
|
||
use counters + `gc`, and mind the hit-rate lag.
|
||
|
||
```
|
||
.logopen C:\Users\erikn\parent-cell-prop.log
|
||
.sympath C:\Users\erikn\source\repos\acdream\refs
|
||
.symopt+ 0x40
|
||
.reload /f acclient.exe
|
||
|
||
r $t0 = 0
|
||
r $t1 = 0
|
||
|
||
* parent crossed a cell: change_cell entry, dump this + target cell
|
||
bp acclient!CPhysicsObj::change_cell "r $t0 = @$t0 + 1; .printf \"CC obj=%p cell=%p newcell=%p children=%p\\n\", @ecx, poi(@ecx+0x90), poi(@esp+4), poi(@ecx+0x44); gc"
|
||
|
||
* per-child re-cell: enter_cell, dump the object and the cell it is being put in
|
||
bp acclient!CPhysicsObj::enter_cell "r $t1 = @$t1 + 1; .printf \"EC obj=%p parent=%p oldcell=%p newcell=%p oldid=%x\\n\", @ecx, poi(@ecx+0x40), poi(@ecx+0x90), poi(@esp+4), poi(@ecx+0x4c); gc"
|
||
|
||
g
|
||
```
|
||
|
||
**Predicted trace if the verdict is right:** equip a weapon, then walk across a
|
||
landblock boundary. Each crossing produces one `CC` line for the player object followed
|
||
immediately by **N+1** `EC` lines — one for the player and one per equipped child — all
|
||
sharing the same `newcell`, and each child's `oldcell` equal to the player's pre-cross
|
||
cell. The child `EC` lines are the propagation; their absence would falsify the verdict.
|
||
|
||
A lighter confirmation, if breakpoint lag on `enter_cell` is a problem (it is called
|
||
often): breakpoint only `change_cell` and, at each hit, walk
|
||
`CHILDLIST* children = poi(@ecx+0x44)` → `objects.data` and dump each child's `+0x90`
|
||
(`cell`) before and after with a second breakpoint on the return. More setup, far fewer
|
||
traps.
|
||
|
||
---
|
||
|
||
## Bottom line
|
||
|
||
Retail re-cells children when the parent crosses a cell boundary. The mechanism is
|
||
`SetPositionInternal` @`0x00515372` → `change_cell` @`0x00513390` →
|
||
`leave_cell` @`0x00510f50` (recursive @`0x00510f84`) + `enter_cell` @`0x00510ed0`
|
||
(recursive @`0x00510f03`), and the child's full cell identity is written at
|
||
@`0x00510ee2` / @`0x00510f1e` / @`0x00510f35`. Children never self-update
|
||
(`update_object` guard @`0x00515d40`), so this is the only mechanism.
|
||
|
||
Route 7 must propagate. A `set_parent`-only write would strand equipped items at
|
||
landblock boundaries.
|