acdream/docs/research/2026-08-04-retail-parent-cell-propagation.md
Erik ca96ea5e32 research: settle retail parent-cell propagation and scope C4 route 3
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>
2026-08-04 21:23:56 +02:00

25 KiB
Raw Blame History

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
0x140x1C 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_positionPackObj vtable ptr
0x4C m_position.objcell_id
0x500x8C m_position.frame (qw..qz, m_fl2gv[9], m_fOrigin)
0x90 CObjCell *cell
0x94 unsigned int num_shadow_objects
0x980xA4 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 @0x00515372change_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 @0x00515660change_cell @0x00515684, guarded by this->cell != arg2 @0x0051567f. (Teleport / corpse forcing — reached from @0x00515c61.)
  • CPhysicsObj::AddObjectToSingleCell @0x005149e0change_cell @0x005149ff.
  • CPhysicsObj::add_obj_to_cell @0x005159e0enter_cell @0x005159e9 directly (then UpdateChildrenInternal @0x00515a17, calc_cross_cells_static @0x00515a1e).
  • CPhysicsObj::set_parent @0x00515a90change_cell @0x00515ad6 (the already-established attach-time write), and the 4-arg overload @0x00515b50change_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); elsechange_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 @0x00515372change_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 @0x00515372change_cell @0x00513390leave_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.