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>
This commit is contained in:
parent
cff52c44f4
commit
ca96ea5e32
2 changed files with 1120 additions and 0 deletions
600
docs/research/2026-08-04-c4-route-3-scoping.md
Normal file
600
docs/research/2026-08-04-c4-route-3-scoping.md
Normal file
|
|
@ -0,0 +1,600 @@
|
||||||
|
# C4 route 3 — portal / local-player placement: SCOPING (2026-08-04)
|
||||||
|
|
||||||
|
**This is scoping, not a pinned contract.** Verified at HEAD **`cff52c44`**, branch
|
||||||
|
`claude/acdream-physics-divergence-5aa784`, clean tree. Line numbers are
|
||||||
|
as-of-HEAD and will go stale; every citation also names the symbol — trust the
|
||||||
|
symbol (process rule 6).
|
||||||
|
|
||||||
|
**Route 3 is NOT a bug fix.** Portalling works today — `/ls`, spell recalls,
|
||||||
|
portal use, and admin teleports all place correctly and are user-accepted
|
||||||
|
behaviour. Route 3 removes a **duplicate placement authority**: the local
|
||||||
|
player's portal arrival is committed by two host-owned hand copies
|
||||||
|
(`LocalPlayerTeleportPlacement.Place` in App,
|
||||||
|
`ResynchronizeLocalPlayerForPortalArrival` in Headless) instead of the one
|
||||||
|
canonical Runtime SetPosition transaction every other C4 route now uses.
|
||||||
|
Nobody should read this document as "portals are broken".
|
||||||
|
|
||||||
|
Route 3 is the LAST C4 route. Routes 1 (C3a-c), 2 (`9966b531`), 4a, 4b-1/2/3,
|
||||||
|
5 (`36255af0`), and 6 (zero production lines, `1b484937`) are landed; route 7
|
||||||
|
is scoped with its research blocker resolved
|
||||||
|
(`2026-08-04-retail-child-cell-ownership.md`).
|
||||||
|
|
||||||
|
Inputs verified for this scoping: the campaign plan
|
||||||
|
(`docs/plans/2026-08-02-placement-cutover.md`), the 2026-08-02 route inventory
|
||||||
|
(**dated — §3 below lists what is now false**), the route-5 contract + its
|
||||||
|
round-3 reviews (the current contract standard), the 4b-3 contract's 13
|
||||||
|
invariants, `claude-memory/project_portal_space.md`, the J6.2/J6.3 closeouts,
|
||||||
|
the six process rules in `2026-08-04-session-handoff-c4-remaining.md`, and
|
||||||
|
direct reads of every file cited below plus the retail pseudo-C.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Headline findings
|
||||||
|
|
||||||
|
1. **The campaign plan overstates what is missing.** Plan line ~92-93 says
|
||||||
|
"`RuntimePortalPlacementAuthority` has zero producing call sites; the
|
||||||
|
adapter from `RuntimeWorldTransitState` does not exist." The first half is
|
||||||
|
still true — zero production producers, confirmed by exhaustive read of
|
||||||
|
every `Begin*`/`TryPrepareAndSubmit*` call site (§4). The second half is
|
||||||
|
now HALF-false: the **consumption/validation side of the adapter exists
|
||||||
|
and is LIVE production code**, exercised (with an empty portal) by every
|
||||||
|
placement in the game:
|
||||||
|
- `RuntimeWorldTransitState.IsCurrentPlacementAuthority`
|
||||||
|
(`RuntimeWorldTransitState.cs:258-275`) — generation + sequence + cell +
|
||||||
|
host-token + not-superseding check;
|
||||||
|
- `RuntimePlacementPresentationSink.TryApply`
|
||||||
|
(`src/AcDream.App/World/RuntimePlacementPresentationSink.cs:100-106`) —
|
||||||
|
every graphical `Place` receipt is already gated on it;
|
||||||
|
- `HeadlessRuntimePlacementProjectionSink.TryApply`
|
||||||
|
(`src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs:100-121`)
|
||||||
|
— the headless twin;
|
||||||
|
- `LiveEntityRuntime.IsValidPortalPlacementAuthority`
|
||||||
|
(`src/AcDream.App/World/LiveEntityRuntime.cs:1448-1457`) — token-shape
|
||||||
|
validation inside every projection-record lookup;
|
||||||
|
- `RuntimeSetPositionState.BeginAcceptedPlacementCore:1528-1534` — Begin
|
||||||
|
refuses a non-empty portal unless kind is `LocalAuthoritative` AND the
|
||||||
|
authority's destination cell equals the record's accepted-position
|
||||||
|
landblock;
|
||||||
|
- the portal authority is threaded through the whole pipeline already:
|
||||||
|
`RuntimeSetPositionCommand.Portal` (`:128`),
|
||||||
|
`RuntimeSetPositionMoverPreparation.Portal`
|
||||||
|
(`RuntimeSetPositionMoverPreparation.cs:121`, consistency-checked in
|
||||||
|
`TryBuild:146`), `Operation.Portal` (`:433`), and
|
||||||
|
`RuntimePlacementProjectionToken.Portal` (`:250`, a REQUIRED positional
|
||||||
|
on every projection token).
|
||||||
|
|
||||||
|
**What is actually missing is only the producer**: nothing constructs a
|
||||||
|
`RuntimePortalPlacementAuthority` with `Present: true` (grep: zero in
|
||||||
|
`src/`; three test constructors), and no portal arrival drives a Runtime
|
||||||
|
placement at all. That makes route 3 materially smaller than the plan's
|
||||||
|
phrasing implies.
|
||||||
|
|
||||||
|
2. **Retail's local portal arrival is the GENERIC path — the pattern from
|
||||||
|
routes 2 and 5 holds a third time** (§5). `SmartBox::TeleportPlayer`
|
||||||
|
@0x00453910 is two calls: `CPhysicsObj::SetPositionSimple(player, dest, 1)`
|
||||||
|
— the EXACT primitive route 2 already routed through Runtime's
|
||||||
|
`CommitCanonical` for ForcePosition, flags 0x1012 — and
|
||||||
|
`SmartBox::PlayerPositionUpdated(this, 1, FLT_MAX)`. There is no dedicated
|
||||||
|
local portal placement path to port; the work is wiring, ordering, and the
|
||||||
|
post-placement suffix.
|
||||||
|
|
||||||
|
3. **The classifier's LocalPlayer+TeleportAdvanced route is fully modeled and
|
||||||
|
steady-state dead** (`RuntimeAuthoritativePositionRouteClassifier.cs:349-368`):
|
||||||
|
`SetPositionSimple` + `AuthoritativeTeleportFlags` + hook
|
||||||
|
`AfterPositionOperation` + constrain `AfterPositionOperation` +
|
||||||
|
`ZeroVelocity: true` + `SendPositionImmediately: false` — matching the
|
||||||
|
retail listing exactly (§5). Its only production caller today is the
|
||||||
|
initial-create continuation executor inside the residence window; route 3
|
||||||
|
would be its first steady-state producer.
|
||||||
|
|
||||||
|
4. **#280 is mechanically distinct from route 3 and must SPLIT** (§7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Site inventory at HEAD (re-verified, not inherited)
|
||||||
|
|
||||||
|
### 2.1 The duplicate authorities to replace (both hosts)
|
||||||
|
|
||||||
|
| # | site (symbol) | at HEAD | what it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | `LocalPlayerTeleportPlacement.Place` | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:214-278` (class `:183-290`) | The graphical duplicate authority, intact and essentially as the 2026-08-02 inventory described plus the 2026-07-25 rebucket fix: `_physics.Resolve(pos, cell, 0, StepUpHeight)` `:219` → `controller.SetPosition` `:230` (which runs the whole teleport tail in `SetPositionCore`, see 2.4) → direct `entity.SetPosition/ParentCellId/Rotation` `:242-244` → `RebucketLiveEntity` (throws on failure) `:254` → `_host.Host?.NotifyTeleported()` `:264` → `SetBodyOrientation` `:265` → camera resets `:267-268` → `_spatial.Reconcile()` `:269`. No Runtime transaction, no receipt, no portal authority. |
|
||||||
|
| D2 | `HeadlessSessionWorldProjection.ResynchronizeLocalPlayerForPortalArrival` | `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:788-836` | The headless duplicate authority — the old `SynchronizeLocalPlayer` body, SURVIVING ONLY FOR THIS ROUTE (its own doc at `:779-787` says "TODO-C4 (route 3)"): `CenterOn` → `Physics.Engine.Resolve(wire, lb, 0, 100f)` `:810` → `Engine.ResolvePlacement(0.48f/1.835f, IsPlayer\|EdgeSlide)` `:816` → `controller.SetPosition` `:831` → `SetBodyOrientation` `:835`. Called from `PrepareDestination:757-762`. |
|
||||||
|
|
||||||
|
### 2.2 The drive machinery (graphical)
|
||||||
|
|
||||||
|
| site | at HEAD | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `LocalPlayerTeleportController` (drive) | `LocalPlayerTeleportController.cs:385-789` | `OnTeleportStarted:436-459` (F751 → `TryQueueTeleportStart`); `OfferDestination:461-470`; `Tick:472-570` — readiness = destination + `!IsRecenterPending` + `_worldReveal.Evaluate(cell).IsReady`; on `TeleportAnimEvent.Place`: `CanPlacePortalDestination` preflight `:514` → `_placement.Place` `:521` → `ObserveMaterialized` `:527` (**still a rubber-stamp AFTER the mutation** — the inventory's structural claim holds); `PlayExitSound` → `RevealWorldViewport`; `FireLoginComplete` → `EnterWorld` + `SendLoginComplete` + `Complete`. |
|
||||||
|
| destination offer | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2950-2957` (was `:1906-1913`) | Last statement of `OnPosition`: `Apply` disposition + local guid → `_localPlayerTeleport.OfferDestination(FromAcceptedPosition(update), timestamps.TeleportAdvanced)`. No placement runs for a local Apply anywhere in `OnPosition` (route 2's ForcePosition drive at `:2014-2071` is disposition-exclusive with it). |
|
||||||
|
| `WorldRevealCoordinator` | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs` (574 lines) | Holds the current `RuntimeWorldHostProjectionToken` PRIVATELY in `_hostProjections` (`:40-62`, `FindCurrentHostProjection:516`); `TryBeginPortal:117-149` mints generation + registers the host projection. **The producer needs read access to this token (or re-derives it via the transit's idempotent `TryRegisterHostProjection`)** — a small exposure decision for the contract. |
|
||||||
|
| local generic render-pose write | `LiveEntityNetworkUpdateController.cs:2284-2289` + `:2314` | **Live finding, verified against the code (the comment at `:2276` is stale):** `TryApplyGenericRemoteRenderPose(entity, null, …)` gate is `OwnsSteadyState(route)` (`:1037-1052`) which is FALSE for the local player's null route — so every accepted local Apply, including the portal DESTINATION Position, writes the raw wire pose onto the local player's `WorldEntity` and rebuckets to the wire landblock while portal space still covers the viewport. Pre-existing, hidden by the portal viewport, later overwritten by `Place`. AP-131/#275 territory — see trap T8. |
|
||||||
|
|
||||||
|
### 2.3 The headless portal flow
|
||||||
|
|
||||||
|
`RuntimeLiveEntitySessionController.TryCompletePortal`
|
||||||
|
(`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-428`),
|
||||||
|
called from `OnPositionUpdated:296` and `OnTeleportStarted:330`:
|
||||||
|
`TryGetAcceptedTeleportDestination` → `TryBeginPortalReveal` →
|
||||||
|
`TryRegisterHostProjection` → ack `ProjectionRegistered` →
|
||||||
|
**`PrepareDestination(generation, destination)`** `:368` (this is where D2
|
||||||
|
runs, plus `controller.State = PlayerState.InWorld` and the readiness report)
|
||||||
|
→ `AcknowledgeDestinationReadiness` → `AcknowledgePortalMaterialized` → ack
|
||||||
|
`SimulationReleaseProjected` → `RequireDestinationReservationRelease` → ack
|
||||||
|
`DestinationReservationReleased` → `AcknowledgeWorldViewportVisible` +
|
||||||
|
`Complete` → ack `TerminalProjected` → `SendGameAction(LoginComplete)` →
|
||||||
|
`EndTeleport`. The J6 lifecycle itself is canonical and healthy; only the
|
||||||
|
placement inside `PrepareDestination` is the duplicate.
|
||||||
|
|
||||||
|
### 2.4 Runtime surfaces route 3 builds against
|
||||||
|
|
||||||
|
| site | at HEAD | relevance |
|
||||||
|
|---|---|---|
|
||||||
|
| `RuntimePortalPlacementAuthority` | `RuntimeSetPositionState.cs:105-119` | `(bool Present, long RevealGeneration, ushort TeleportSequence, RuntimeWorldHostProjectionToken Projection)`. **The shape is RIGHT for what transit holds** — it is exactly the tuple `RuntimeWorldTransitState` owns per reveal (`Snapshot.Generation`, `ActiveTeleportSequence`, the registered host token whose `DestinationCell` is validated everywhere). No shape change needed. |
|
||||||
|
| `RuntimeWorldTransitState` | `RuntimeWorldTransitState.cs` (1,012 lines) | Producer inputs all exist: `IsTeleportActive:96`, `ActiveTeleportSequence:97`, `TryGetAcceptedTeleportDestination:522`, `CanPlacePortalDestination:534` (read-only preflight), `TryRegisterHostProjection:189` (idempotent for the same generation/cell — a legitimate token re-derivation path), `AcknowledgePortalMaterialized:595`, `IsCurrentPlacementAuthority:258`. |
|
||||||
|
| `RuntimeAcceptedPositionDriveController` | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (924 lines) | Route 2's landed local-player accepted-Position seam over `TryBeginExclusiveAuthoredPlacement:389` + `TryPrepareAndSubmitAuthoredPlacement:677` (`resolveWorldOffsetFromRuntimeFrame: true`). **ForcePosition-only by gate** (`:340-360`); its `Pending`/ack funnel is ForcePosition-shaped (`PositionEventOwed`). The natural home for a sibling portal arm — but see trap T7 before inheriting any of its funnel. |
|
||||||
|
| `PlayerMovementController` teleport tail | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1845-1920` (`SetPositionCore`) | Today's `controller.SetPosition` on the portal path performs, in one method: body snap, render-lerp anchor reset, `UpdateCellId` publication, Contact\|OnWalkable\|Active transient set, zero velocity, `StopCompletelyAtPhysicsObjectBoundary` (retail StopCompletely 0x00527E40), input-edge/mouse state reset, `UnStick` + `UnConstrain` + `RearmConstraintLeashAtCurrentPosition` (retail @0x0045418A analog), and `_objectClock.ResetForEnterWorld()`. Under a canonical commit the BODY write moves into Runtime's `CommitCanonical`; **every one of these controller-local duties must be re-homed into a teleport analog of `CommitCanonicalForcePositionFrame` (`:1944-1950`)** — losing any one of them is the 4b-2 round-1 defect class. This is the single largest and riskiest piece of the slice. |
|
||||||
|
| classifier LocalPlayer teleport branch | `RuntimeAuthoritativePositionRouteClassifier.cs:349-368` | See §1.3. Production classify callers: the drive controller `:922` (Force only), `ClassifyRemoteAcceptedPosition` (remote/projectile), the continuation executor `:1928` (residence window). |
|
||||||
|
| `SpawnPlacementSettler` | `src/AcDream.Core/Physics/SpawnPlacementSettler.cs:22`; local-player call site `RuntimeLocalPlayerPhysicsPublicationState.cs:812` | First-entry only (C3c-F5/AD-61). **Not invoked on the portal path today, and retail's portal arrival has no settle sweep either** — contact resolves inside `SetPosition`'s own transition (SLIDE flags). Do not add it (trap T10). |
|
||||||
|
| world frame | `RuntimePhysicsState.ObserveLocalWorldFrame:550-562`; callers `RuntimeEntityObjectLifetime.cs:1739/:1794` | Runtime's world frame rebases on `teleportAdvanced` at the accepted-Position merge; App's `LiveWorldOriginState` recenters asynchronously afterwards. See trap T4. |
|
||||||
|
|
||||||
|
### 2.5 What is already canonical and must not regress (J6)
|
||||||
|
|
||||||
|
Slice J6.2/J6.3/J6.4 ownership stands: `RuntimeWorldTransitState` owns the
|
||||||
|
reveal generation, destination latch, F751 correlation (both packet orders),
|
||||||
|
materialization/simulation edge, viewport observation, wait cue, completion/
|
||||||
|
cancellation, and the 4-stage host acknowledgement suffix. Route 3 must not
|
||||||
|
move ANY of that; it only makes the transit owner additionally gate the
|
||||||
|
placement mutation it currently rubber-stamps. The lifecycle gate's
|
||||||
|
`transitOwnership` zero-at-stable-checkpoint discipline
|
||||||
|
(`tools/run-connected-world-lifecycle-gate.ps1`) already covers this state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The dated inventory: claims now false
|
||||||
|
|
||||||
|
The 2026-08-02 route inventory predates C3c, route 2, the four route-4
|
||||||
|
sub-slices, route 5, and the OnPosition collapse. **Eight of its claims
|
||||||
|
bearing on routes 3/8 are now materially false**, plus pervasive stale line
|
||||||
|
numbers:
|
||||||
|
|
||||||
|
1. *"The binding machinery is real but 100% dormant end to end."* — FALSE.
|
||||||
|
The validation half is live at three production layers (§1.1) and runs for
|
||||||
|
every placement (empty-portal case). Only the producer is dormant.
|
||||||
|
2. *"Neither host has ever constructed a production `IRuntimePlacementObserver`
|
||||||
|
implementation"* (prerequisite D "100% unbuilt on the host side") — FALSE.
|
||||||
|
`RuntimePlacementPresentationSink` (graphical) and
|
||||||
|
`HeadlessRuntimePlacementProjectionSink` (headless, wired at
|
||||||
|
`HeadlessSessionHost.cs:701`) are production consumers of the placement
|
||||||
|
FIFO.
|
||||||
|
3. *"`BeginAcceptedPlacement`/`BeginAuthoredPlacement` … zero external callers
|
||||||
|
repo-wide"* / *"a placement transaction … never begun anywhere in
|
||||||
|
production"* — FALSE. Three production controllers drive
|
||||||
|
`TryBeginExclusiveAuthoredPlacement` + `TryPrepareAndSubmitAuthoredPlacement`
|
||||||
|
(local force, remote, first-entry). (The two named legacy wrappers
|
||||||
|
`BeginAcceptedPlacement`/`BeginAuthoredPlacement` themselves are now
|
||||||
|
test-only entry points.)
|
||||||
|
4. *"`InboundPhysicsStateController.TryApplyPosition` is today's only
|
||||||
|
PRODUCTION Position wire caller; the classifier-based path is test-only"*
|
||||||
|
— FALSE since routes 2/4a/4b-2/4b-3/5.
|
||||||
|
5. The entire route-2 chain (`LocalForcePositionTransaction`, pre-commit ack,
|
||||||
|
generic-tail double write for ForcePosition) — GONE; route 2 landed.
|
||||||
|
6. Headless route-8: *"`SynchronizeLocalPlayer` (566-615) is the duplicate
|
||||||
|
placement authority … `CreateController` (639-655) constructs
|
||||||
|
`PlayerMovementController`"* — FALSE at HEAD. C3c deleted the route-1/8
|
||||||
|
initial-entry hand-copy and the controller construction; the ONLY
|
||||||
|
surviving headless duplicate is the renamed portal-arrival re-resolve (D2).
|
||||||
|
7. *"`BlipLocalPlayer` (617-637) direct blip"* — FALSE; deleted by route 2
|
||||||
|
(halves split into `CenterOnAcceptedForcePosition` + the drive controller).
|
||||||
|
8. *"The executor publishes only generic entity deltas; nothing bridges its
|
||||||
|
completion to `RuntimePlacementProjectionChannel`"* — FALSE since C0/C3-1
|
||||||
|
(`ExecutorCompleted` receipts; `TryGetInitialCreateCompletion`).
|
||||||
|
|
||||||
|
Still TRUE from the inventory's route-3 section: the call-chain shape
|
||||||
|
(F751 → queue → offer → aim → `TryBeginPortal` → Place at anim event), the
|
||||||
|
`Place`-mutates-then-`ObserveMaterialized`-rubber-stamps sequencing, the
|
||||||
|
"discard/cancellation semantics structurally sufficient but UNEXERCISED for a
|
||||||
|
real placement" warning (now with live validators, still zero portal-carrying
|
||||||
|
operations — the explicit cutover tests it demanded are still owed), and the
|
||||||
|
executor scope note (portal arrival is a steady-state placement, not a
|
||||||
|
Create admission — the FIFO executor is not the target).
|
||||||
|
|
||||||
|
Stale-but-substantively-true: the offer site (`:1906` → `:2950`), the
|
||||||
|
validator (`:1162` → `:1448`), `PrepareDestination` (`:539` → `:752`,
|
||||||
|
re-resolve retained under a new name), `RuntimeWorldTransitState` still 1,012
|
||||||
|
lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The missing producer, precisely
|
||||||
|
|
||||||
|
What route 3 must build (and ALL it must build on the authority side):
|
||||||
|
|
||||||
|
1. **The producer**: at the placement moment, construct
|
||||||
|
`new RuntimePortalPlacementAuthority(Present: true, RevealGeneration,
|
||||||
|
ActiveTeleportSequence, hostProjectionToken)` from the live transit state.
|
||||||
|
Graphical: the generation is already at hand
|
||||||
|
(`_pendingRevealGeneration`, `LocalPlayerTeleportController:404/694`), the
|
||||||
|
sequence is `_transit.ActiveTeleportSequence`, and the host token needs a
|
||||||
|
small accessor on `WorldRevealCoordinator` (or re-derivation via the
|
||||||
|
idempotent `TryRegisterHostProjection`). Headless: all three are in scope
|
||||||
|
inside `TryCompletePortal` (generation, `destination.TeleportSequence`,
|
||||||
|
`projection`).
|
||||||
|
2. **The placement call**: a portal arm on the local-player drive seam that
|
||||||
|
runs `TryBeginExclusiveAuthoredPlacement(record, version,
|
||||||
|
LocalAuthoritative, portal)` + `TryPrepareAndSubmitAuthoredPlacement(…,
|
||||||
|
portal, resolveWorldOffsetFromRuntimeFrame: true)` with the classifier's
|
||||||
|
LocalPlayer-teleport route, invoked at each host's existing placement edge
|
||||||
|
(graphical: the `TeleportAnimEvent.Place` handler after
|
||||||
|
`CanPlacePortalDestination`; headless: `PrepareDestination`).
|
||||||
|
3. **The controller-local teleport frame commit** (§2.4's `SetPositionCore`
|
||||||
|
re-homing) invoked on the committed receipt.
|
||||||
|
4. **Re-sequencing**: `AcknowledgePortalMaterialized` fires from the committed
|
||||||
|
placement receipt (it then actually gates — `IsCurrentPlacementAuthority`
|
||||||
|
requires the materialization state it sets), camera reset +
|
||||||
|
`_spatial.Reconcile()` become acknowledge-edge reactions, and D1/D2's
|
||||||
|
direct mutations are deleted.
|
||||||
|
|
||||||
|
**The authority's shape does not need changing** (§2.4). One genuine edge to
|
||||||
|
design for: `BeginAcceptedPlacementCore:1528-1534` validates the authority's
|
||||||
|
destination cell against `record.Snapshot.Physics?.Position ??
|
||||||
|
record.Snapshot.Position` — the LATEST merged Position — while transit pins
|
||||||
|
the FIRST accepted destination per generation (J6.3). A second local Position
|
||||||
|
merging between the offer and the Place edge would make Begin refuse
|
||||||
|
(default token). Today that window is benign (ACE sends one destination per
|
||||||
|
teleport), but the refusal path must be defined and tested rather than
|
||||||
|
discovered: on refusal the transit generation must be cancelled or the
|
||||||
|
placement re-aimed — never a silent wedge in portal space.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Retail ground truth (verified in `acclient_2013_pseudo_c.txt` for this scoping; verify again at contract time)
|
||||||
|
|
||||||
|
| claim | address | status |
|
||||||
|
|---|---|---|
|
||||||
|
| F751 (`SmartBox::HandlePlayerTeleport`) writes exactly three flags — `position_update_complete = 0`, `has_been_teleported = 0`, `waiting_for_teleport = 1` — after a wrap-safe TELEPORT_TS check. No position, no cell, no physics. | @0x00452150, writes @0x00452193-0x004521A7 | ✓ read |
|
||||||
|
| `HandleReceivedPosition`'s local/non-local split is `arg2 != this->player` @0x0045414D. The LOCAL teleport branch (`newer_event(TELEPORT_TS)` @0x0045415F): `TeleportPlayer` @0x00454168 → **`ConstrainTo(player, &var_48 /* the WIRE destination */, start, max)` @0x0045418A** → **`set_velocity(player, 0, 1)` @0x004541B4** → return. | @0x00453FD0 | ✓ read |
|
||||||
|
| `SmartBox::TeleportPlayer` is TWO calls: `CPhysicsObj::SetPositionSimple(player, dest, 1)` @0x00453924 and `PlayerPositionUpdated(this, 1, FLT_MAX)` @0x00453932. **The generic placement primitive — the same one route 2 ported** (`BlipPlayer` @0x00453940 is the identical shape with arg 0). | @0x00453910 | ✓ read |
|
||||||
|
| `SetPositionSimple(…, 1)` builds flags **0x1012** = SEND_POSITION_EVENT \| SLIDE \| TELEPORT (`acclient.h:6123`) → generic `CPhysicsObj::SetPosition` @0x005160C0. Identical flags to the remote teleport branch. SLIDE is consumed in `CheckPositionInternal` @0x00511E90 (accept what find_valid_position resolved). | @0x005162B0 (0x1012 @0x005162C2) | ✓ read |
|
||||||
|
| `PlayerPositionUpdated` teleport arm (AFTER the placement): `position_update_complete = 0`, **`waiting_for_teleport = 0` (the F751 wait ends at PLACEMENT, not at reveal)**, `has_been_teleported = 0`, **`teleport_hook(player)`** @0x004538AE, `cmdinterp->PlayerTeleported()` (= `SetAutoRun(0,1)` + `SendMovementEvent`, @0x006B32B0), `set_viewer(&player->m_position, 1)` (camera reset, reset_sought), `LScape::update_viewpoint(0)` (destroys the terrain draw list), `CellManager::ChangePosition(&m_position, 1 /* blocking */)`. | @0x00453870 | ✓ read |
|
||||||
|
| **The hook ordering FLIPS versus the remote route**: local runs `teleport_hook` @0x00514ED0 AFTER `SetPositionSimple` returns (from `PlayerPositionUpdated`); the remote branch runs it BEFORE `SetPosition` (@0x005163EF). The hook `UnConstrain`s @0x00514F0C, and `HandleReceivedPosition` re-arms `ConstrainTo` @0x0045418A after `TeleportPlayer` returns, so the leash survives. The classifier already encodes this (`TeleportHookPhase.AfterPositionOperation` local vs `BeforePositionOperation` remote). | | ✓ read |
|
||||||
|
| **`CPhysicsObj::enter_world` @0x00516170 is NOT on the portal path.** Its local-player caller is the initial-login path only (@0x00455095, with `store_position` + blocking `ChangePosition`). Portal arrival never re-runs `enter_world` or `CPartArray::HandleEnterWorld`/`MovementManager::HandleEnterWorld` — the cell install happens inside `SetPosition` itself. **`2026-07-16-portal-completion-pseudocode.md` §2.1 attributes portal arrival to `enter_world` — that is the login path and the doc needs a correction note** (its conclusion about committing the cell before releasing simulation survives; the cited mechanism is wrong). | callers of 0x00516170 | ✓ read (agent-verified caller sweep) |
|
||||||
|
| The FORCE_POSITION branch (@0x0045400C → `BlipPlayer` → `SendPositionEvent` → return @0x0045409D) never reaches any `ConstrainTo` — route 2's finding reconfirmed. The local TELEPORT branch DOES arm, wire-anchored, and DOES zero velocity — **the inversion trap T1**. | | ✓ read |
|
||||||
|
| Prefetch (#280's oracle, re-verified): blocking `CellManager::ChangePosition` @0x004559B0 releases the old landscape and calls `CellManager::PreFetchCells` @0x00455820 → outdoor `LScape::PreFetchCells` @0x00505660 walking **±`mid_radius`** landblocks (DAT-record residency, not GPU upload); indoor `CEnvCell::PreFetchCells`. `mid_radius` = `Render::m_RenderPrefs.LandscapeDrawDistance` via `SmartBox::SetRegion` @0x00453227 / `set_mid_radius` @0x00453180 — **the prefetch window IS the landscape draw distance, centred on the destination**. While `blocking_for_cells`, `SmartBox::UseTime` @0x00455410 runs ONLY `CheckPrefetchStatus`; object/physics/landscape/game-time/ambient all hold. Reveal (SmartBox::Show) is a further TunnelFadeOut second after the readiness edge; LoginComplete a WorldFadeIn second after that. | | ✓ read |
|
||||||
|
| Retail places IMMEDIATELY on the accepted destination Position and blocks SIMULATION on prefetch behind the portal viewport. acdream inverts this: it holds the PLACEMENT until reveal-readiness (`ready` in `LocalPlayerTeleportController.Tick`) and then places. This is the accepted portal-presentation architecture (J6/E5, user-gated repeatedly) — **route 3 keeps the deferred-Place timing and changes only WHO commits it** (trap T3). | | ✓ (architecture fact) |
|
||||||
|
|
||||||
|
Unverified residuals from the retail pass (mark for the contract, none
|
||||||
|
load-bearing for scope): the exact second argument of `teleport_hook` at
|
||||||
|
@0x004538AE (decompiler-elided, almost certainly 1); `CheckPrefetchStatus`'s
|
||||||
|
apparent 5.0 s re-check throttle (@0x00455BEE — suspicious, byte-verify if
|
||||||
|
#280 uses it); where SEND_POSITION_EVENT/TELEPORT flag bits are consumed
|
||||||
|
below `SetPositionInternal` (not traced; irrelevant to route 3's seam
|
||||||
|
choice).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Shape sketch (scoping altitude — the contract pins the real design)
|
||||||
|
|
||||||
|
- **Runtime**: a portal arm on `RuntimeAcceptedPositionDriveController` (a
|
||||||
|
sibling entry point, e.g. `TryExecuteAcceptedPortalArrival(record, route,
|
||||||
|
authority)`), sharing Begin/Submit/status handling with the force arm but
|
||||||
|
NOT its `PositionEventOwed`/re-issue funnel (trap T7). It consumes the
|
||||||
|
classifier's LocalPlayer-teleport route, applies `ZeroVelocity`, invokes
|
||||||
|
the controller-local teleport frame commit on the committed receipt, and
|
||||||
|
arms the leash post-operation (the route's `ConstrainPhase` — today's
|
||||||
|
`SetPositionCore` re-arm relocates here).
|
||||||
|
- **Graphical**: `LocalPlayerTeleportPlacement.Place` becomes
|
||||||
|
acknowledge-only — build the authority, call the Runtime arm, and on the
|
||||||
|
committed receipt run the presentation suffix (camera resets,
|
||||||
|
`NotifyTeleported`, `_spatial.Reconcile()`), then
|
||||||
|
`ObserveMaterialized`. The direct Resolve/SetPosition/entity-write/rebucket
|
||||||
|
bodies are deleted; the render entity moves via the existing
|
||||||
|
`RuntimePlacementPresentationSink` Place projection (whose portal gate
|
||||||
|
finally goes live).
|
||||||
|
- **Headless**: `PrepareDestination` keeps `CenterOn` + readiness reporting +
|
||||||
|
state writes; `ResynchronizeLocalPlayerForPortalArrival` is deleted and the
|
||||||
|
same Runtime arm is driven from the portal completion flow.
|
||||||
|
- **Probe**: `ACDREAM_PROBE_LOCAL_TELEPORT=1` (PhysicsDiagnostics-owned,
|
||||||
|
TEMPORARY family) — one line per portal arrival with cause
|
||||||
|
(portal/recall), placement status, portal generation/sequence,
|
||||||
|
destination cell, and hook/leash confirmation.
|
||||||
|
|
||||||
|
Register bookkeeping to expect (contract finalizes): AD-42's headless
|
||||||
|
portal-arrival-resync citation dies with D2; locate-or-add the row recording
|
||||||
|
the deferred-place timing adaptation (§5 last row — **UNVERIFIED whether an
|
||||||
|
existing AD row states it**; if none exists the route-3 commit must add it —
|
||||||
|
a deviation without a row is a bug twice over); the 2026-07-16 pseudocode
|
||||||
|
correction note (§5); the stale `:2276` comment (§2.2) corrected or the
|
||||||
|
finding filed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. #280 — definitive recommendation: SPLIT (do not bundle into route 3)
|
||||||
|
|
||||||
|
**The campaign plan and the session handoff disagree, and the plan is
|
||||||
|
right.** The plan lists #280 as its own numbered work item (item 3 of the
|
||||||
|
remaining campaign work), separate from "finish C4's routes 2-7" (item 2).
|
||||||
|
Only the 2026-08-04 session handoff says "#280 rides with [route 3]". They
|
||||||
|
cannot both be followed; this scoping recommends the plan's sequencing.
|
||||||
|
|
||||||
|
Why they are mechanically distinct:
|
||||||
|
|
||||||
|
- **Route 3 changes WHO commits the placement.** Its blast radius is
|
||||||
|
`RuntimeSetPositionState`/drive-controller/`PlayerMovementController`/the
|
||||||
|
two host placement bodies. It does not touch readiness evaluation at all —
|
||||||
|
the `ready` predicate in `LocalPlayerTeleportController.Tick:489-493` and
|
||||||
|
everything behind `WorldRevealCoordinator.Evaluate` is read, not written.
|
||||||
|
- **#280 changes WHAT the reveal waits for.** Its fix shape (per its own
|
||||||
|
ISSUES entry, whose retail oracle this scoping re-verified §5): replace
|
||||||
|
`WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius = 1`
|
||||||
|
(`src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs:38`) with a
|
||||||
|
retail-derived, quality-configured destination prefetch window
|
||||||
|
(`mid_radius` == landscape draw distance) and hold one generation-scoped
|
||||||
|
reservation across terrain/statics/EnvCells/render
|
||||||
|
publication/composites/collision until the complete visible window is
|
||||||
|
ready. Its blast radius is streaming/reveal/render-resource scheduling —
|
||||||
|
`WorldRevealReadinessBarrier`, `GpuWorldState` readiness joins, the
|
||||||
|
streaming reservation schedulers. It does not touch placement.
|
||||||
|
- The ONLY shared surface is the transit lifecycle both sit inside — and J6
|
||||||
|
already owns that; neither change moves it.
|
||||||
|
- Bundling would put a placement-authority cutover (physics-review skillset,
|
||||||
|
connected teleport gate) and a streaming-window change (render/streaming
|
||||||
|
skillset, view-distance visual gate at multiple quality settings) in one
|
||||||
|
diff, and would push the slice far past the ~500-line budget (#280 alone
|
||||||
|
plausibly rivals route 3 in size). 4b-2's process lesson ("split on
|
||||||
|
discovery; a defect outside scope rides for three review rounds") applies
|
||||||
|
prospectively here.
|
||||||
|
|
||||||
|
Sequencing: #280 can land before or after route 3 with no dependency in
|
||||||
|
either direction — route 3 keeps the Place gated on the SAME `ready`
|
||||||
|
predicate, whatever radius that predicate uses. Recommend: route 3 first
|
||||||
|
(closes C4), #280 immediately after as its own slice with its own visual
|
||||||
|
gate, before C5's closeout matrix (the campaign plan already sequences it
|
||||||
|
exactly there).
|
||||||
|
|
||||||
|
One honest caveat: bundling advocates would note both changes gate "what
|
||||||
|
happens at the Place edge" and a single connected session could gate both.
|
||||||
|
True but insufficient — a shared test session is not a shared mechanism, and
|
||||||
|
the review lenses are disjoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Size estimate and split recommendation
|
||||||
|
|
||||||
|
Calibration: route 4a 364; 4b-1 230+57; 4b-2 350-500; 4b-3 ~250 net new
|
||||||
|
(400-700 budget) with 739 deleted; route 5 ~131 added, ~180 deleted.
|
||||||
|
|
||||||
|
| piece | est. non-comment production lines |
|
||||||
|
|---|---|
|
||||||
|
| Runtime portal arm on the drive controller (Begin/Submit/status/receipt handling, ZeroVelocity, leash arm) | 90-150 |
|
||||||
|
| `PlayerMovementController` teleport frame commit (re-home the `SetPositionCore` suffix; split shared private helpers) | 40-80 |
|
||||||
|
| Graphical producer + `Place` rewrite + `ObserveMaterialized`/camera/reconcile re-sequencing | 60-110 (deletes ~55) |
|
||||||
|
| `WorldRevealCoordinator` host-token exposure | 10-20 |
|
||||||
|
| Headless: `PrepareDestination` flip + drive call | 15-30 (deletes ~50 incl. D2) |
|
||||||
|
| Probe line | ~10 |
|
||||||
|
| **Total added** | **~225-400** |
|
||||||
|
|
||||||
|
Net roughly +150 to +250 (deletions ~105-120). Tests are the larger share, an
|
||||||
|
estimated 500-900 lines (per-outcome placement tests on the portal arm,
|
||||||
|
supersession/cancellation/stale-generation discard tests — the inventory's
|
||||||
|
still-owed "explicit cutover test" for the discard semantics — presentation
|
||||||
|
assertions at #312's layer, dual-host parity, the Begin cell-mismatch edge of
|
||||||
|
§4).
|
||||||
|
|
||||||
|
**Recommendation: ONE slice, no split** — comfortably under the ~500
|
||||||
|
threshold PROVIDED #280 splits out (§7). Stop-and-report conditions for the
|
||||||
|
implementer: added production lines exceed ~500; the design starts needing
|
||||||
|
changes to `RuntimeWorldTransitState`'s lifecycle semantics, the
|
||||||
|
`TeleportAnimSequencer` timings, or the readiness barrier (each is a sign
|
||||||
|
#280 or a J6 regression is being smuggled in); or the Begin cell-mismatch
|
||||||
|
edge (§4) turns out to be reachable in ordinary play.
|
||||||
|
|
||||||
|
Allocation: a portal placement is once-per-teleport, not frame-frequency —
|
||||||
|
the C2 1,536 B budget is not an activation blocker here (route 2's
|
||||||
|
reasoning applies). Do not spend lines pooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. The connected gate (real and mandatory — unlike route 5)
|
||||||
|
|
||||||
|
Portals are trivially reachable and user-visible; a live gate exists and is
|
||||||
|
required. **A clean-looking session is NOT a pass** (process rule 5; 4b-3's
|
||||||
|
gate was a pass only because 16 probe lines proved the arm executed, and its
|
||||||
|
cell-less half is still honestly recorded as unverified).
|
||||||
|
|
||||||
|
**Recipe (user-run, graphical):** Release build, `ACDREAM_RETAIL_UI=1`,
|
||||||
|
`ACDREAM_PROBE_LOCAL_TELEPORT=1`, live ACE. Exercise, in one session:
|
||||||
|
1. a physical portal (e.g. Holtburg portal) — outdoor destination;
|
||||||
|
2. a dungeon portal (indoor destination, EnvCell readiness path);
|
||||||
|
3. `/ls` lifestone recall and one spell recall (the F751 recall family);
|
||||||
|
4. an ACE admin teleport of the LOCAL player (`@teleto`/`@teleloc`) — this
|
||||||
|
advances ObjectTeleport and, per the route-2 visual-gate doc, exercises
|
||||||
|
exactly this route;
|
||||||
|
5. a same-destination revisit (ACE may omit CreateObject on revisit);
|
||||||
|
6. graceful close.
|
||||||
|
|
||||||
|
**Pass requires ALL of:**
|
||||||
|
- one probe line per arrival showing the CANONICAL path executed:
|
||||||
|
`placement=Committed`, the portal authority's generation/sequence, and the
|
||||||
|
resolved destination cell — zero arrivals through the deleted path (which
|
||||||
|
no longer exists to run) and zero `Refused/Contention` lines in ordinary
|
||||||
|
play;
|
||||||
|
- user visual: purple materialization silhouette without an opaque pop or
|
||||||
|
late tail (the 2026-07-25 accepted baseline must not regress), camera
|
||||||
|
reset behind the player at arrival, movement works immediately (walk out
|
||||||
|
with W held — the input-edge reset half of the teleport frame commit),
|
||||||
|
idle stance not a run-in-place, **no rubber-band or tether after arrival
|
||||||
|
AND no leash absence** (the leash IS armed on this route — a creature-free
|
||||||
|
way to observe it is beyond a visual check, so add a probe field
|
||||||
|
`leash=armed` instead of inventing a visual);
|
||||||
|
- the exact lifecycle/reconnect gate
|
||||||
|
(`tools/run-connected-world-lifecycle-gate.ps1`) passes with every
|
||||||
|
`transitOwnership` counter zero at every stable checkpoint — this is the
|
||||||
|
supersession/cancellation convergence evidence;
|
||||||
|
- **headless parity**: the K-style connected portal route (four-stop portal
|
||||||
|
routing from the K3 closeout) with the probe enabled — same
|
||||||
|
probe-line-per-arrival requirement, proving D2's replacement executed on
|
||||||
|
the no-window host.
|
||||||
|
|
||||||
|
**Known gap to state up front, 4b-3-style:** mid-transit supersession (a
|
||||||
|
second teleport starting before the first materializes) and mid-transit
|
||||||
|
disconnect are hard to provoke against ACE on demand. If the session does
|
||||||
|
not produce them, record the stale-generation discard behaviour as
|
||||||
|
test-verified-only — do not fold it into a blanket "gate passed".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Traps — rules from earlier routes that flip or do not transfer
|
||||||
|
|
||||||
|
- **T1 — route 2's leash rule INVERTS.** Route 2 pinned "the constraint
|
||||||
|
leash is NOT re-armed" because the FORCE_POSITION branch returns at
|
||||||
|
@0x0045409D before every `ConstrainTo`. The local TELEPORT branch is the
|
||||||
|
opposite: retail arms `ConstrainTo` @0x0045418A (anchored at the received
|
||||||
|
destination) AND zeroes velocity @0x004541B4. The classifier already
|
||||||
|
carries both (`ConstrainPhase.AfterPositionOperation`,
|
||||||
|
`ZeroVelocity: true`); the drive-controller doc comment
|
||||||
|
(`RuntimeAcceptedPositionDriveController.cs:127-132`) explaining why the
|
||||||
|
force arm never arms MUST NOT be generalized to the portal arm. Also
|
||||||
|
inverted: `SendPositionImmediately` is FALSE (no `AutonomousPosition` ack
|
||||||
|
on this route — retail's teleport branch returns without
|
||||||
|
`SendPositionEvent`; the outbound tail is LoginComplete, which stays where
|
||||||
|
it is, see T11) and `PreserveHeading` is FALSE (the wire orientation
|
||||||
|
applies).
|
||||||
|
- **T2 — 4b-3's hook ordering INVERTS.** Remote: `teleport_hook` BEFORE the
|
||||||
|
placement (@0x005163EF). Local: the hook runs AFTER `SetPositionSimple`,
|
||||||
|
from `PlayerPositionUpdated` @0x004538AE. The classifier encodes the flip
|
||||||
|
(`AfterPositionOperation` vs `BeforePositionOperation`). Copying 4b-3's
|
||||||
|
hook-first arm shape onto the local route would be retail-wrong. Also: the
|
||||||
|
local "hook" is not `RemoteTeleportHook` — its live actions for the local
|
||||||
|
player are the controller-local UnStick/UnConstrain (+ re-arm afterwards),
|
||||||
|
`NotifyTeleported` (TargetManager pair), the autorun cancel
|
||||||
|
(`PlayerTeleported` → `SetAutoRun(0,1)` — verify against the J5.4 autorun
|
||||||
|
latch at contract time), and `report_collision_end`.
|
||||||
|
- **T3 — do NOT move the placement to packet-accept time.** Retail places
|
||||||
|
immediately and blocks simulation on DAT prefetch; acdream's accepted
|
||||||
|
portal architecture defers the placement to the reveal-ready Place edge
|
||||||
|
behind the portal viewport (user-gated repeatedly; J6/E5). Route 3 changes
|
||||||
|
the executor of the Place, never its timing. An implementer "fixing" the
|
||||||
|
timing toward retail would regress the whole accepted presentation and
|
||||||
|
collide with #280's territory. Locate-or-add the register row for this
|
||||||
|
adaptation (§6).
|
||||||
|
- **T4 — two world frames disagree during the teleport window (#283).**
|
||||||
|
Runtime's frame rebases at the accepted teleport Position's merge
|
||||||
|
(`ObserveLocalWorldFrame(…, teleportAdvanced: true)`); App's
|
||||||
|
`LiveWorldOriginState` recenters asynchronously. Today `Place` runs only
|
||||||
|
after `!IsRecenterPending` (inside `ready`), so both frames agree at
|
||||||
|
placement time. The canonical arm must keep that gating and must resolve
|
||||||
|
through Runtime's frame (`resolveWorldOffsetFromRuntimeFrame: true`, as
|
||||||
|
route 2 does) — the App-translated `_pendingPosition` vector dies with D1.
|
||||||
|
A placement submitted during the disagreement window is the failure mode.
|
||||||
|
- **T5 — the Begin cell-mismatch edge** (§4): transit pins the FIRST
|
||||||
|
accepted destination per generation; Begin validates against the LATEST
|
||||||
|
merged snapshot. Define and test the refusal path; never wedge in portal
|
||||||
|
space.
|
||||||
|
- **T6 — hands off J6 and the presentation.** `RuntimeWorldTransitState`'s
|
||||||
|
lifecycle semantics, the 4-stage host acknowledgement, the
|
||||||
|
`TeleportAnimSequencer` seven-state machine, AD-38's viewport-retire
|
||||||
|
timing, the wait cue, and `TeleportViewPlaneController` are all
|
||||||
|
user-accepted and out of scope. The ONLY transit-adjacent change is that
|
||||||
|
`AcknowledgePortalMaterialized` fires from the commit receipt instead of
|
||||||
|
rubber-stamping after a host mutation.
|
||||||
|
- **T7 — do not inherit the force arm's funnel.** `PositionEventOwed`, the
|
||||||
|
`_newestForce` re-issue observation (AD-62), and `SendImmediatePosition`
|
||||||
|
are ForcePosition-shaped: ACE never repeats a portal destination, the
|
||||||
|
portal route owes no AutonomousPosition ack, and a dead-watch re-issue of
|
||||||
|
a portal placement would fight the transit generation. The portal arm
|
||||||
|
shares Begin/Submit/receipt handling only. (The mirror of 4b's "do NOT
|
||||||
|
port route 2's re-issue funnel" lesson.)
|
||||||
|
- **T8 — the local generic render-pose write is a pre-existing second
|
||||||
|
writer; do not silently delete OR silently keep it.** Every accepted local
|
||||||
|
Apply — including the portal destination Position — writes the raw wire
|
||||||
|
pose to the local player's `WorldEntity` and rebuckets to the wire
|
||||||
|
landblock (`:2284-2289`/`:2314`; the `:2276` comment claiming otherwise is
|
||||||
|
stale). Hidden by the portal viewport today and overwritten by Place. It
|
||||||
|
is AP-131/#275 (the ordinary local Apply path no route owns) — route 3
|
||||||
|
must TOLERATE it (the canonical projection overwrites on commit) and the
|
||||||
|
contract must decide explicitly whether the teleport window suppresses it;
|
||||||
|
whichever way, correct the stale comment (process rule 6).
|
||||||
|
- **T9 — no allocation work.** Once-per-teleport; the C2 budget is not in
|
||||||
|
play.
|
||||||
|
- **T10 — no settle sweep.** `SpawnPlacementSettler` is first-entry-only
|
||||||
|
(C3c-F5); retail's portal arrival has none — contact resolves inside the
|
||||||
|
SLIDE placement itself. If arrival contact looks wrong in the gate, the
|
||||||
|
bug is in the placement flags/transition, not a missing settle.
|
||||||
|
- **T11 — LoginComplete does not move.** It stays on
|
||||||
|
`TeleportAnimEvent.FireLoginComplete` (retail: ~2 s after reveal; TS-28 is
|
||||||
|
narrowed to initial login only). C3c's `175ad6b0` moved LOGIN's
|
||||||
|
LoginComplete to the first-placement terminal edge — that reasoning does
|
||||||
|
NOT transfer to the portal route.
|
||||||
|
- **T12 — headless keeps its non-placement duties.** `PrepareDestination`'s
|
||||||
|
`CenterOn`, readiness report, and `PlayerState` writes stay host-owned;
|
||||||
|
only the D2 re-resolve body flips. `BeginTeleport`'s direct
|
||||||
|
`PlayerState.PortalSpace` write is out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. What route 3 does NOT do / what remains for C5
|
||||||
|
|
||||||
|
- **#280** — split out (§7); its own slice with its own visual gate.
|
||||||
|
- **AP-131 / #275** — the shared merge call and the ordinary local Apply
|
||||||
|
path (including T8's generic write) stay for C5.
|
||||||
|
- **AP-1/AD-1 retirement, the legacy-deletion sweep, parity tests, the
|
||||||
|
final connected matrix** — C5. Route 3's deletions are only D1/D2's bodies
|
||||||
|
and their direct wiring; any surviving dead seams
|
||||||
|
(`ILocalPlayerTeleportPlacement` if it reduces to a trivial adapter,
|
||||||
|
legacy `BeginAcceptedPlacement`/`BeginAuthoredPlacement` test-only
|
||||||
|
wrappers) are C5 sweep candidates, recorded not deleted here.
|
||||||
|
- **Route 7** (pickup/parent/delete) — separate, already scoped.
|
||||||
|
- **No headless remote consumer, no reveal-gate changes, no presentation/
|
||||||
|
anim/viewport changes, no `enter_world`/`HandleEnterWorld` additions**
|
||||||
|
(retail does not run them on this path — §5).
|
||||||
|
- The six `LiveEntityRuntimeTests` fixture failures named in the campaign
|
||||||
|
handoff and the final-binary soak remain campaign-level obligations, not
|
||||||
|
route-3 scope.
|
||||||
|
|
||||||
|
## 12. Open gaps (each with the check that closes it)
|
||||||
|
|
||||||
|
1. **The deferred-place register row** — grep the divergence register for
|
||||||
|
the portal place-at-readiness adaptation; if absent, the route-3 commit
|
||||||
|
adds it (T3).
|
||||||
|
2. **The autorun cancel** — confirm acdream's arrival path cancels autorun
|
||||||
|
(retail `PlayerTeleported` → `SetAutoRun(0,1)`): read the J5.4
|
||||||
|
`RuntimeLocalPlayerMovementState` autorun latch against
|
||||||
|
`SetPositionCore`'s input reset.
|
||||||
|
3. **`WorldRevealCoordinator` token exposure shape** — accessor vs
|
||||||
|
re-registration; decide at contract with a test that a superseded token
|
||||||
|
can never be handed to the producer.
|
||||||
|
4. **The Begin cell-mismatch refusal path** (§4/T5) — design + test.
|
||||||
|
5. **Retail residuals** from §5's unverified list (teleport_hook arg,
|
||||||
|
prefetch throttle) — byte-verify only if the contract leans on them.
|
||||||
|
6. **`RuntimePlacementPresentationSink` Place projection for the local
|
||||||
|
player** — route 2's B2 recorded that no test drives a local-player
|
||||||
|
Place end-to-end through the sink to a moved `WorldEntity`; route 3 makes
|
||||||
|
that seam load-bearing for portals and MUST close the coverage gap (an
|
||||||
|
App-layer test asserting the render entity moved from the committed
|
||||||
|
receipt), not inherit it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary for the campaign plan
|
||||||
|
|
||||||
|
- **Size: ~225-400 added non-comment production lines, net ~+150-250 — ONE
|
||||||
|
slice, no split** (contingent on #280 splitting out).
|
||||||
|
- **#280: SPLIT.** The handoff's "rides with route 3" is wrong; the campaign
|
||||||
|
plan's own sequencing (separate item) is right. Reveal-window/streaming
|
||||||
|
concern, not placement.
|
||||||
|
- **Eight dated-inventory claims now false** (§3), the most consequential
|
||||||
|
being "100% dormant end to end" — the validation half of the portal
|
||||||
|
machinery is live production code; only the producer is missing.
|
||||||
|
- **Contradictions with the campaign plan:** (a) plan line ~92-93's "the
|
||||||
|
adapter … does not exist" overstates the gap — only the producer is
|
||||||
|
missing; (b) the plan's #280 sequencing contradicts the session handoff's
|
||||||
|
bundling — this scoping sides with the plan; (c) the inventory's "route
|
||||||
|
3's target is `RuntimeSetPositionState` directly" is refined: the target
|
||||||
|
is a portal arm on route 2's `RuntimeAcceptedPositionDriveController` seam
|
||||||
|
OVER `RuntimeSetPositionState`.
|
||||||
|
- **Two documentation defects found while verifying:** the 2026-07-16 portal
|
||||||
|
pseudocode attributes portal arrival to `enter_world` (that is the LOGIN
|
||||||
|
path — needs a correction note), and
|
||||||
|
`LiveEntityNetworkUpdateController.cs:2276`'s "the local player never
|
||||||
|
reaches this generic-remote code path" is false for
|
||||||
|
`TryApplyGenericRemoteRenderPose`/`RebucketLiveEntity` (T8).
|
||||||
520
docs/research/2026-08-04-retail-parent-cell-propagation.md
Normal file
520
docs/research/2026-08-04-retail-parent-cell-propagation.md
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
# 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue