fix(physics): bind a parented child to the parent's live incarnation (#319)

A player-parented child never received a canonical cell. Its FullCellId stayed
0 for its whole attached lifetime, so it could not follow the player across a
boundary. Scope was wider than the local player: every REMOTE player's
equipment too.

ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0
for a parented CreateObject. Correct for creatures and statics, which really
are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins
(ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while
the record carried TotalLogins, so both route-7 write sites — D1's attach
re-cell and D2's propagation lookup — keyed on an incarnation that never
matched. TryCommitParent did not validate the sequence, so the attach
succeeded and printed normally. Silent.

A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call
route 7 deleted was keyed on the child guid alone and was structurally immune
to a wrong parent key.

THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them.
Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id
@0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e,
with SetChildren @0x00509370 hash-walking by guid — and neither set_parent
overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any
player test or instance-sequence read. Our player/non-player split was purely
an artifact of keying relations by (guid, incarnation) against a wire message
that carries no parent incarnation. Late-binding to whoever currently holds the
guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and
OnCreateParentAccepted, the second carrying the byte-identical defect and not
named in the contract's scope line.

THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I
offered: every one of the 45 FullCellId liveness predicates excludes a
committed child on a NON-cell clause first, so the child inherits only the
parent record's existing staleness, which is already present today with no
symptom. The key fix alone restores child-equals-parent for every parent class.

TWO SITES GATED, inert only because the cell was zero and would have woken
wrongly: the hydration candidate loop (a nonzero-cell child would take the
legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route
7's exact defect class) and RestoreShadow (would install a broadphase row for
the weapon, the #184 shape, contradicting route 7's P4). Retail anchor:
update_object's parent != 0 early-out @0x00515D40 — children are never
independently re-placed.

THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for
an unaddressable parent, carrying a missing child-freshness gate (A2), a
sentinel-0 collision with the generation filters (A3), and unbounded
accumulation (A5). Both reviewers then proved the deferred branch unreachable
for BOTH producers — RegisterEntityCore defers the entire CreateObject one
layer above, reading the same ?? chain, and CreateParentUpdate is produced only
inside AcceptCreateCore, after that gate passes. The machinery was deleted
rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24
while gaining the A1 fix. Retail confirmed the deletion does not diverge:
acdream's real port of retail's per-guid replay (QueueBlobForObject) is a
different, untouched layer, and the deleted queue was a third redundant one
downstream of it.

THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw
InvalidOperationException AFTER the canonical half had committed, so the one
time it fired it left the child parented with no committed relation and a
staged one blocking Resolve — a torn transaction, the exact outcome the
contract pinned against. Now a pure CanCommitIncarnation precondition checked
BEFORE the commit at both sites, with a logged refusal instead of a throw.
Route 3's N3 principle (do not make a transient fatal on a host that must
survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands
alone.

TEST QUALITY, the recurring lesson in its most refined form. The A1 test
initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence
meant TryCommitParent's own gate refused in either ordering, so the three
assertions carrying A1's meaning passed both ways and only an incidental
staging assertion failed. It failed on stranding, not tearing. Corrected, the
sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the
parent's guid in it — proving the canonical mutation happened before the catch.
"Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is
the real question.

The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8…
incarnation 0, identical outcomes, sabotage-verified in both directions) is the
structural fix for how this survived a full dual review and two connected
sessions: every prior test and both captured gate logs used sequence-0 parents.

Register: AP-142 clause (f); AP-132 amended to distinguish the two producers;
new row AP-146 for the local player's coarse canonical cell (retail writes it
per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO
walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8,
so retail's per-tick child propagation lives in the same function). That
divergence had no row at all, a standing rule-1 violation now corrected.
Follow-up #320 filed for making the player's cell track ordinary movement —
deliberately excluded here: it touches the landblock-preserve contract, the
Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four
review rounds pinning, and the portal-space frozen-source-cell race.

Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed.
Diagnostic refusals are latched per child guid and the latch clears on
Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather
than being silently suppressed.

Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed
(baseline 11,090 at 52175aa1, +22). Neither known flake fired.

STILL OWED: the connected gate, with the CORRECTED positive criterion — assert
the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is
a failure, not a silence), run with BOTH a player and a creature parent, plus
the new step carrying an armed creature across a landblock unload/reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 11:56:31 +02:00
parent af828a8a2a
commit 392c1e22c1
19 changed files with 3076 additions and 20 deletions

View file

@ -24,6 +24,79 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #320 — The local player's canonical cell does not track ordinary movement (follow-up from #319)
**Status:** OPEN
**Severity:** LOW today (no observed symptom — see below); the correctness
question is real and unresolved
**Filed:** 2026-08-05, filed in the #319 fix commit per that contract's §2.2/§4/§9
**Component:** physics / entity lifetime / local player canonical cell
**Description.** Retail writes the local player's cell on EVERY physics tick
(`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional). acdream's
canonical `FullCellId` for the LOCAL player is written only at three edges:
login activation (`RuntimeSetPositionState.cs:2741-2745`), an accepted inbound
Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot`
`RuntimeEntityRecord.cs:234`), and a teleport/portal placement commit
(`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`).
Ordinary WASD movement passes a LANDBLOCK id, not an exact cell
(`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in
both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the
prior canonical cell for that shape. So the player's canonical cell is coarse
and mostly-frozen between teleports — see the register row this issue's fix
commit files (AP-146) for the full citation and the argument that this is
currently safe for every EXISTING consumer.
**Why this is not #319's blast radius.** #319's fix makes a player-parented
equipped child inherit the parent's (the player's) canonical cell EXACTLY —
an equality invariant, not a freshness one. The child is stale-but-equal
wherever the player's own record already is; #319's fix does not touch the
player's own cell-writing paths at all. See
[`docs/research/2026-08-05-issue-319-contract.md`](research/2026-08-05-issue-319-contract.md)
§2.2 for the full argument that folding this into #319 would have put that
slice at route-3 scale (~418 lines) and was the wrong bundling regardless.
**What this issue must resolve before implementation, not after (§2.2's
enumeration + §9 item 2):**
1. Making the player's canonical cell exact-track movement touches the
deliberate landblock-preserve contract at `LiveEntityRuntime.cs:935-938`
a generic rebucket rule, not player-specific, so changing its input
population changes it for the one caller that relies on it.
2. `Rebucketed` entity-delta publication cadence: today the player NEVER
publishes a `Rebucketed` delta during WASD (the preserve path early-outs
at `CommitRebucket`'s `previous == fullCellId`,
`RuntimeEntityObjectLifetime.cs:1965-1972`); an exact-cell commit would
publish per EnvCell crossing — audit every consumer before shipping.
3. The accepted-Position classification inputs for the LOCAL player (route 2
and the 4b-3 `PreMergeCommittedCellId` measurement at
`TryApplyPosition:1801-1814`): a fresh committed cell changes the
pre-merge population on live correction paths AP-136/AP-138 spent four
review rounds pinning.
4. The portal-space freeze interaction
(`LocalPlayerProjectionController.Project:100-103` — the teleport owner
alone projects the destination while the local controller deliberately
retains its frozen source cell): a canonical exact-cell writer must not
race the teleport owner.
5. The `isOrdinaryRoot` family (`LiveEntityRuntime.cs:915-918`, `:3213`,
`:3323`) and the animation-scheduler local-player exclusion
(`LiveEntityAnimationScheduler.cs:183-227`).
6. **First verification step, unresolved by the #319 investigation or its
fix:** whether the local player is a `RuntimePhysicsState` spatial root,
and if so whether a stale-cell landblock retirement (a player WASD-ing
beyond the streaming radius from its last teleport, with no intervening
teleport or inbound Position) can sweep the player into
`ParkCollisionResidents`. The connected routes exercised so far all
teleport between stops, which refreshes the cell and may be masking this.
Establish this BEFORE deciding whether exact-cell tracking is even
optional — if the player can already be swept today, that is a separate,
more urgent bug independent of this issue's scope.
**Do not implement without a fresh retail-conformance argument** — this is a
design call (which of the two cells is the source of truth for a
client-authoritative parent), not a bug fix, per the #319 contract's §2
verdict.
## #318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering
**Status:** OPEN
@ -13263,11 +13336,60 @@ by `LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved` /
## #319 — A player-parented child never receives a canonical cell (ParentInstanceSequence hardcoded 0)
**Status:** OPEN
**Status:** FIX IMPLEMENTED, awaiting the connected acceptance gate (§7 of the
contract) and commit — NOT YET COMMITTED in this worktree. Do not mark DONE
until the gate runs and the change lands.
**Severity:** LOW for the user (no observable symptom — verified, not assumed),
HIGH for process (it defeats route 7's own connected gate; see below)
**Filed:** 2026-08-05
**Component:** physics / entity lifetime / equipped children
**Fix summary (implementation, revised after the dual review — retail PASS,
architecture FAIL/6 MAJORs, both 2026-08-05).** Both CreateObject-carried
producers (`EquippedChildRenderController.OnSpawn` for a raw CreateObject and
`OnCreateParentAccepted` for the same-generation `CreateParentUpdate`
envelope — the contract named only `OnSpawn`; `OnCreateParentAccepted` has the
identical structural defect and was fixed alongside it) now route through
`AcceptLateBoundCreateObjectRelation`: if the parent's live snapshot is known
at accept time (always true in production — see A6 below), stage the relation
with the parent's live `InstanceSequence`; otherwise log a loud refusal with
no state mutation (a deferred-relation queue was tried here and REMOVED after
the review; see A6). `ParentAttachmentState.CanCommitIncarnation` is a pure,
side-effect-free precondition checked BEFORE either half of a parent-attach
commit mutates anything (moved there by architecture review finding A1: the
original shape checked from inside `CommitProjection`, reached only AFTER the
canonical commit had already landed, so a mismatch tore the transaction —
canonically parented, no committed relation, a staged relation blocking
`Resolve` forever). It logs and returns `false` rather than throwing (Route
3's N3 principle: a possibly-transient condition must not be fatal on a host
that must survive long endurance sessions) — wired from both the App producer
and the headless `RuntimeLiveEntitySessionController`, both now checking it
BEFORE their canonical commit. Two structural gates
(`LiveEntityHydrationController.OnLandblockLoaded`,
`LiveEntityPresentationController.RestoreShadow`) now refuse a record with a
committed parent, closing the two call sites the contract's §3.1/§3.2 flagged
as inert-only-because-the-cell-is-zero (§3.2's premise was corrected by
architecture finding A4: the gate is a live behavior change for
CREATURE-parented children, route 7's D1 already re-cells them nonzero — see
the connected gate's Half B watch item). **A6 — the deferred-relation
question, decided:** an initial revision queued a CreateObject-carried
relation whose parent was not yet addressable and adopted the parent's live
incarnation once it arrived. Both independent reviews proved this queue was
structurally unreachable in production for BOTH producers —
`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate`
gate defers the ENTIRE CreateObject (both wire shapes) before either producer
ever runs — while it carried three latent defects of its own (a missing child
POSITION_TS gate, a placeholder-incarnation collision with the generation
filters, unbounded mid-session accumulation), exercised only by a test that
bypassed production routing. Deleted rather than fixed in place: dead code
carrying three defects is a worse trade than a loud refusal for a case the
layer above already guarantees cannot happen. New ledger-convergence tests
(dual-parent-class: child removal, parent removal, full teardown) close the
gap this decision would otherwise have left untested. Full contract:
[`docs/research/2026-08-05-issue-319-contract.md`](research/2026-08-05-issue-319-contract.md).
Follow-up filed as #320 (the local player's canonical cell does not track
ordinary movement — deliberately NOT bundled into this fix). Register:
AP-142 clause (f), AP-132 clarifying sentence, new row AP-146.
**Regressed by:** `cd3129e9` (C4 route 7), which un-masked a pre-existing latent
bug rather than creating it.

File diff suppressed because one or more lines are too long

View file

@ -747,6 +747,19 @@ App-layer tests (`tests/AcDream.App.Tests`):
## 7. Gates
> **SUPERSEDED 2026-08-05 (#319).** This section's gate criterion ("A session
> counts as a pass ONLY if the probe shows the propagation executed") is
> UNFALSIFIABLE in the presence of #319's defect — a zero-cell player child
> emits NO `[child-cell]` line at all, which this criterion reads as "clean"
> rather than "broken." Two captured gate sessions passed this exact
> criterion while carrying the defect. The corrected criterion (a positive
> equality assertion — the equipped child's `FullCellId` equals the parent's
> after a crossing — instantiated for BOTH parent classes) lives in
> [`2026-08-05-c4-closeout-handoff.md`](2026-08-05-c4-closeout-handoff.md)
> and is run at
> [`2026-08-05-issue-319-contract.md`](2026-08-05-issue-319-contract.md) §7.
> Do not re-run this section's recipe as written; use the corrected one.
- **Focused**: the §6 suites, green.
- **Complete Release suite**:
`$env:ACDREAM_PAK_PATH = "$env:USERPROFILE\Documents\Asheron's Call\acdream.pak"`,

View file

@ -0,0 +1,287 @@
# Issue #319 — architecture / adversarial review, ROUND 2 (delta) — 2026-08-05
**Verdict: FAIL — one blocking finding, a one-line test change.**
Round 1: [`2026-08-05-issue-319-architecture-review.md`](2026-08-05-issue-319-architecture-review.md).
**Every production concern from round 1 is closed, and I verified each by
reading the code rather than accepting the summary.** The A1 ordering fix is
correct on both hosts, the A6 deletion argument is provable and all three of
its links check out, and A2/A3/A5 are gone by deletion rather than relocated.
The single blocker is evidentiary: the new test that exists specifically to
guard the A1 ordering property has three headline assertions that are
**vacuous in its own fixture**, so the guard against the exact regression it
was written for is one incidental assertion. That is fixable by changing one
constant.
Verification performed for this round:
- `dotnet build -c Release` — succeeded, 0 warnings, 0 errors.
- `AcDream.Runtime.Tests` (`ParentAttachmentStateTests`,
`RuntimeEntityChildCellPropagationTests`) — **38/38** (was 32).
- `AcDream.App.Tests` (`EquippedChildProjectionWithdrawalTests`,
`LiveEntityHydrationControllerTests`, `LiveEntityPresentationControllerTests`,
`LiveEntityRuntimeTests`) — **208/208** (was 206).
- Production delta measured independently: **76 non-comment lines added, 13
removed** — matches the stated 76/13, down from 91/24.
---
## B1 — MAJOR, BLOCKING — `PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit`'s three canonical assertions cannot fail, in either ordering
**`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs`**,
the new A1 test.
The test injects `wrongRelation` with `ChildPositionSequence: 1` against a
child created by `fixture.RegisterOnly(childGuid, generation: 1, hasPosition: true)`.
That helper builds the spawn from `ControllerFixture.SpawnData`, which sets
`Timestamps.Position: 0` and never sets the top-level `PositionSequence`
(`WorldSession.EntitySpawn`'s `ushort PositionSequence = 0` default,
`WorldSession.cs:163`). Unlike its sibling
`NoPositionCreateParent_CommitsAfterParentPartArrayValidation`, this test does
**not** call `TryApplyCreateParent`/`TryApplyParent` first, so nothing ever
advances the child's POSITION_TS.
The canonical half is
`CommitStagedParent``RuntimeEntityObjectLifetime.TryCommitParent:1406-1412`
`InboundPhysicsStateController.TryCommitParent:299-312`, whose gate is:
```csharp
if (!TryGet(childGuid, out gate, out child)
|| gate.PositionTimestamp != positionSequence // 0 != 1
|| child.PositionSequence != positionSequence) // 0 != 1
{ accepted = default; return false; }
```
`positionSequence` is `relation.ChildPositionSequence` = 1; both gate values
are 0. **The canonical commit refuses on POSITION_TS before the incarnation is
ever consulted — in either ordering.**
**Consequence.** Revert the fix (put `CanCommitIncarnation` back after
`CommitStagedParent`, or delete it entirely) and trace the test:
| assertion | reverted-order outcome |
|---|---|
| `Assert.Null(snapshot.ParentGuid)` | **still passes**`TryCommitParent` refused, snapshot untouched |
| `Assert.NotNull(snapshot.Position)` | **still passes** — same reason |
| `Assert.False(TryGetCommittedParent(...))` | **still passes**`CommitProjection` never reached |
| `Assert.False(TryGetStagedProjection(...))` | fails — `return default` leaves the relation staged |
So the test does fail under sabotage — the letter of "every new test must fail
against broken behaviour" is met — but it fails on the *stranding* assertion,
not the *tearing* one. The claim relayed to me ("asserts the child's snapshot
stays un-parented; sabotage-verified by reverting the order") is literally true
and materially misleading: the snapshot assertions never execute against a live
canonical commit, so they assert nothing.
**Why this blocks rather than being cosmetic.** The regression this guard
exists to catch is "someone lets the canonical half run before the incarnation
check." A plausible future variant — moving the check back inside
`CommitProjection` while *also* calling `RejectProjection` on refusal — would
re-tear the transaction and **pass this test on all four assertions**. The
guard does not cover its own finding.
**Fix (one line).** Change `wrongRelation`'s `ChildPositionSequence: 1` to `0`
so it matches the fixture's gate. Then under a reverted ordering
`TryCommitParent` succeeds, nulls `snapshot.Position`, writes `ParentGuid`, and
all three canonical assertions bite — while the fixed code still refuses at
`CanCommitIncarnation` (which reads only the parent incarnation and is
independent of POSITION_TS) and `ValidateParentProjection` still returns
`Ready`. Re-run the sabotage and confirm the failure message names one of the
three snapshot assertions, not the staged-projection one.
---
## What I verified as CLOSED
### A1 — ordering fix: torn-transaction window genuinely closed on both hosts ✓
- `ParentAttachmentState.CanCommitIncarnation:589-605` is genuinely pure: it
reads `resolveParentInstance` and writes only stderr. No table is touched.
- **Graphical** (`EquippedChildRenderController.cs:974-989`): the pre-check runs
before `CommitStagedParent`, refuses via `RejectProjection`, and returns
`CanAdvanceWireQueue: true`. Correct on three counts I checked separately:
(i) no canonical mutation precedes it; (ii) `RejectProjection` clears
`_stagedByChild`, so `Resolve`'s early return (`:457-458`) is unblocked —
the round-1 "stranded forever" outcome is gone; (iii) `CanAdvanceWireQueue: true`
lets `ResolveAndTryRealize`'s `while (true)` loop continue, and it still
terminates, because each iteration either breaks or consumes one relation from
the finite `_unresolvedByChild` queue, and `Resolve` can only re-stage on
exact incarnation equality — which is precisely the condition
`CanCommitIncarnation` accepts.
- **Headless** (`RuntimeLiveEntitySessionController.cs:408-417`): same
pre-check, before `TryCommitParent`, with `RejectProjection` + `return false`.
- `CommitProjection` now returns `false` instead of throwing, and its internal
check (`:628-629`) sits after the `_stagedByChild` lookup but before
`RemoveCommittedChild`/`_lastAcceptedByChild`, so it mutates nothing before
refusing.
- The residual window I looked for does not exist: between the pre-check and
`CommitProjection`, the only mutation is `TryCommitParent` writing the
**child's** snapshot; the parent's `_snapshots` entry — the tripwire's input —
is untouched, so the two reads cannot disagree.
Route 3's N3 principle is now honoured: the condition is refused loudly and
recoverably, never fatally.
### A6 — the deletion argument: all three links independently verified ✓
This was the justification for deleting rather than fixing, so I checked it
rather than accepting it.
1. **`RegisterEntityCore` defers the whole CreateObject.**
`RuntimeEntityObjectLifetime.cs:795-812`:
`uint parentGuid = incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u;`
then `if (beginInitialResidence && parentGuid != 0u && !Entities.TryGetActive(parentGuid, out _))`
`EnqueueDeferredCreate` + `DeferredForParent: true`. Confirmed this is the
**earliest** branch in the method that can admit a create — it precedes
`PreviewCreateDisposition` (`:818`), the pending-residence early return, and
`AcceptCreate` (`:866-868`).
2. **`beginInitialResidence` is true for every graphical-host CreateObject.**
`LiveEntityRuntime.cs:583` is the sole App registration entry and uses
`RegisterEntityWithInitialResidence`. The three internal replay/drain sites
(`RuntimeEntityObjectLifetime.cs:234`, `:317`, `:400`) also use it, so the
gate re-applies on deferred-create replay rather than being bypassed by it.
3. **`CreateParentUpdate` cannot precede the gate.** It exists only inside
`InboundCreateResult.SameGenerationEvents`, produced by
`BuildSameGenerationEvents` in `InboundPhysicsStateController.AcceptCreate`
(`:87`) / `AcceptCreateDeferredSameGeneration`, both called at
`RuntimeEntityObjectLifetime.cs:866-868` — after the gate. I traced every
consumer and all three originate there:
`LiveEntityHydrationController.cs:340` (`result.SameGenerationEvents`),
`LiveEntityNetworkUpdateController.cs:400-401` (via
`ApplySameGeneration`/`LiveEntitySameGenerationUpdateRouter`), and
`RuntimeEntityObjectLifetime.cs:2544` (`AdmitSameGenerationCreate`, also
post-gate).
**The chain holds.** `AcceptLateBoundCreateObjectRelation`'s `else` branch is
structurally unreachable for both producers, and the deletion removes no real
path.
### A2 / A3 / A5 — gone by deletion, not relocated ✓
Repo-wide grep for `DeferCreateObjectRelation`, `LateBindParentInstance`, and
`lateBind` returns **zero** hits in `src/` and `tests/` (only unrelated
`InteractionUiLateBindings` matches). `ParentAttachmentState.Resolve` is
byte-identical to HEAD — the diff touches only `CanCommitIncarnation` and
`CommitProjection`, and `ParentAttachmentRelation` gains no field. So:
- **A2** (missing child POSITION_TS gate on a relation that skipped `accept`) —
no relation skips `accept` any more; `Resolve`'s single path is the original
ParentEvent one.
- **A3** (placeholder `ParentInstanceSequence = 0` misread by
`FilterParentCandidates`) — no relation is enqueued with a placeholder; the
only unresolved-queue writer is the pre-existing `Enqueue`.
- **A5** (unbounded accumulation of deferred CreateObject relations) — that
population no longer exists. The surviving `_unresolvedByChild` accumulation
question is pre-existing ParentEvent behaviour, untouched by this fix.
The replacement — a loud stderr line with **no state mutation** — is the right
trade: if the upstream invariant ever breaks, the failure mode is a
non-attached (cell-less, position-less) child plus a log line, not a resurrected
cross-generation relation.
### Test 9 — covers the shapes I named ✓
Three dual-class tests in `ParentAttachmentStateTests`. Checked against the A2/A5
shapes specifically:
- `LedgerConvergence_ChildRemoval_ZeroesEveryTable` asserts all four counters
(`CommittedRelationCount`, `RecoveryRelationCount`, `StagedRelationCount`,
`UnresolvedRelationCount`) **plus** the `_committedChildrenByParent` reverse
index via `ChildrenAttachedToParent` — that reverse index is the table
round 1's stranding scenarios would have leaked into, and asserting it
separately from `HasCommittedParent` is the right call.
- `LedgerConvergence_ParentRemoval_ZeroesEveryTable` covers `RemoveObject`'s
parent-reference sweep — the A2 "relation outlives its parent" half.
- `LedgerConvergence_Teardown_ZeroesEveryTableWithMixedPendingState` holds a
committed child and an unresolved ParentEvent simultaneously before `Clear()`
— the A5 mixed-state shape.
Each fails against broken behaviour non-vacuously (they establish nonzero
counts before the removal and assert zero after, so a no-op removal fails).
### A4 — sufficient ✓
The `RestoreShadow` comment now states the correction plainly (route 7's D1
already gives creature-parented children a nonzero cell, so the clause **is** a
live behaviour change for that class), and contract §7 Half B now carries the
explicit watch instruction (`:549`, `:554`). Given the population is bounded by
`ShadowObjectRegistry.UpdatePosition`'s unregistered-entity no-op
(`ShadowObjectRegistry.cs:696-697`), naming it in the connected gate is the
right resolution — a synthetic test cannot settle it, and the gate is where it
gets observed.
### A8 — confirmed non-blocking ✓
`AcceptCreateObjectRelation` remains public with an optional resolver. With A1's
pre-check now at both production sites, a future producer regression is caught
before any mutation, so the cost of tightening the API is not worth the ~12
test call sites.
---
## Non-blocking observations
**B2 — LOW — the gate and the producer test different oracles.** The upstream
gate (`RuntimeEntityObjectLifetime.cs:797`) uses `Entities.TryGetActive`
(active-record table); `AcceptLateBoundCreateObjectRelation` uses
`_liveEntities.TryGetSnapshot` (the `InboundPhysicsStateController._snapshots`
map). Active ⇒ snapshot holds because `AddActive` is fed from the snapshot
`AcceptCreate` wrote, but there is a narrow inversion inside `TryDeleteEntity`:
`TryDelete` removes `_snapshots[guid]` (`InboundPhysicsStateController.cs:101-102`)
several statements before `RemoveActive` (`RuntimeEntityObjectLifetime.cs:~2094`).
Reaching the `else` branch through it would need a re-entrant child CreateObject
inside that window — not reachable from a single-threaded pump. Worth one
sentence in the remark, since the remark's argument is phrased in the gate's
predicate but enforced with a different one.
**B3 — LOW — the structural invariant is host-scoped and the remark does not
say so.** `RuntimeLiveEntitySessionController.cs:144` calls
`Entities.RegisterEntity` (`beginInitialResidence: false`) when
`_worldProjection is null`, which bypasses the parent-deferral gate entirely.
That host has no `EquippedChildRenderController`, so the producer is
unreachable there — but the remark reads as an unconditional claim. One clause
("in the graphical host; the content-less direct host has no CreateObject-carried
relation producer at all") would make it audit-proof.
**B4 — INFO — the refusal is unconditionally destructive.** `RejectProjection`
discards the relation, where `Resolve`'s equivalent staleness rule
(`ParentAttachmentState.cs:484-498`) is conditional: a relation naming a
*newer*-than-live parent generation is retained until that generation arrives.
Unreachable today, because a staged relation can only have been staged on exact
equality — but if the staging rules ever loosen, discard is the wrong arm for
the packet-ahead case.
**B5 — INFO — `CommitProjection`'s XML doc slightly overclaims.** "non-tearing
on its own terms for ANY caller" is true of *this method's* tables, not of a
caller's canonical state: a caller that commits canonically first and then calls
`CommitProjection` still tears when the internal check refuses. That is exactly
the shape A1 removed, and both production sites now pre-check, so the guard is
genuine belt-and-braces — the sentence just shouldn't imply it protects callers
who get the ordering wrong.
**B6 — LOW — unbounded stderr on a per-packet path.** Both new
`Console.Error.WriteLine` sites (`AcceptLateBoundCreateObjectRelation`'s `else`,
`CanCommitIncarnation`'s refusal) log unconditionally. If the invariant ever
breaks for a repeating producer, this spams once per packet. A log-once-per-guid
latch would suit the "should be structurally unreachable — investigate if seen"
framing better.
**B7 — INFO — test 9's scope.** The three tests exercise `RemoveChild`,
`RemoveObject`, and `Clear` — not `DeleteGeneration`/`EndGeneration`, the two
paths carrying the `WaitOwner is Parent` retention rule
(`ParentAttachmentState.cs:819-821`, `:844-846`). That rule is pre-existing and
unchanged by this fix, so it is legitimately out of scope; the ledger claim
simply should not be read as covering generation boundaries.
---
## What a PASS needs
**One change:** `ChildPositionSequence: 1``0` in
`PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit`'s
`wrongRelation`, then re-run the ordering sabotage and confirm the failure names
a snapshot assertion rather than the staged-projection one.
Everything else in this round is PASS-quality. B2/B3 are one-clause comment
improvements; B4B7 are informational.

View file

@ -0,0 +1,421 @@
# Issue #319 — independent architecture / adversarial review (2026-08-05)
**Verdict: FAIL.**
Reviewed: the uncommitted working tree at branch
`claude/acdream-physics-divergence-5aa784`, HEAD `af828a8a` (`git diff HEAD`
plus the untracked contract doc). Production delta measured independently:
**91 non-comment lines added, 24 removed** across five files — the
implementer's claim 3 is exact.
Verification performed for this review (not inherited):
- `dotnet build -c Release` — green (exit 0).
- `AcDream.Runtime.Tests` filtered to `ParentAttachmentStateTests` +
`RuntimeEntityChildCellPropagationTests` — 32/32 pass.
- `AcDream.App.Tests` filtered to `EquippedChildProjectionWithdrawalTests` +
`LiveEntityHydrationControllerTests` + `LiveEntityPresentationControllerTests`
+ `LiveEntityRuntimeTests` — 206/206 pass.
The key fix (F1's staged half) is correct, well-anchored, and its tests are
genuinely sabotage-sensitive. The bookkeeping (AP-142 clause (f), AP-132
clarification, new AP-146, issue #320, route-7 §7 supersession note) is the
most thorough in the campaign so far. **The FAIL rests on two things: the
commit-time tripwire is wired at the wrong point in the transaction so that
when it fires it tears the commit it was built to protect (A1), and the
deferred late-bind branch — the larger and more novel half of F1 — is
production-unreachable, carries a freshness hole its ParentEvent sibling does
not, and its one test reaches it only by bypassing the production seam under
an incorrect stated rationale (A2, A3, A6).**
---
## A1 — MAJOR — the tripwire throws *after* the canonical parent commit has already landed; when it fires it produces a torn transaction, which is the one outcome F1 pinned against
**`src/AcDream.App/Rendering/EquippedChildRenderController.cs:955-963`**
```csharp
if (candidateKind is ParentProjectionCandidateKind.Staged)
{
if (!_liveEntities.CommitStagedParent(relation, out _) // canonical commit
|| !Relations.CommitProjection(relation, ResolveLiveParentInstance)) // throws here
{
return default;
}
```
**`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:408-412`** —
identical ordering:
```csharp
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|| !relations.CommitProjection(staged, _resolveParentInstance))
```
`CommitStagedParent`/`TryCommitParent` is the *canonical* half. It runs
`InboundPhysicsStateController.TryCommitParent``ApplyParent`
(`InboundPhysicsStateController.cs:1347-1373`), which nulls the child's
snapshot `Position`, writes `ParentGuid`/`ParentLocation`/`PlacementId`, and
stamps POSITION_TS. Only *then* does `CommitProjection`
(`ParentAttachmentState.cs:651-661`) evaluate the tripwire and throw.
**Failure scenario.** Any input that reaches the tripwire with a mismatch:
the child's canonical snapshot has already been rewritten as parented (its
world Position destroyed, POSITION_TS advanced), `_lastAcceptedByChild` has
*not* been written, and the relation is still sitting in `_stagedByChild`.
The exception then unwinds through `PrepareAndTryRealize``Tick()` /
the inbound sink. Net state: a child that the canonical layer believes is
parented, with no committed relation for D1/D2 to find, and a staged relation
that `Resolve`'s early return (`ParentAttachmentState.cs:457-458`) will
now block forever for that child.
That is strictly worse than the silent mis-keyed commit #319 shipped. The
contract's F1 pinned outcome was "**never a silent success under a mismatched
key**"; the XML doc on `CommitProjection` promises it "refuses loudly rather
than silently filing the relation under a key D1/D2 can never find again."
Neither is what the code does — it half-files, then throws.
**On reachability, stated honestly.** I traced every path that can advance a
parent's live `InstanceSequence` while a staged relation naming that parent
survives, and found none reachable today:
- Both producers set the sequence from the live snapshot at stage time
(`EquippedChildRenderController.cs:845-857`; `ParentAttachmentState.Resolve`
`:473-498`).
- The only incarnation bump is a `NewGeneration` CreateObject, and
`RuntimeEntityObjectLifetime.cs:1004-1010` calls
`ParentAttachments.EndGeneration(...)``RemoveParentReferences(_stagedByChild, guid)`
(`ParentAttachmentState.cs:825`), purging every staged relation naming that
parent.
- Delete removes both the gate and the snapshot
(`InboundPhysicsStateController.cs:101-102`) and is immediately followed by
`DeleteGeneration` (`RuntimeEntityObjectLifetime.cs:2088-2090`), which purges
the same way — so after a delete the producers *defer* rather than stage a
stale key, and the next create is `InitialGeneration` (no `EndGeneration`
needed).
So the mismatch is unreachable — **but only as an emergent property of a
three-file invariant chain that nothing records**, and one link is
order-fragile: `AcceptCreate` publishes the new incarnation into `_snapshots`
*before* `RegisterEntityCore` reaches `EndGeneration`, with `RemoveActive` and
`WithdrawCommittedChildrenToCellless` (which publishes deltas to synchronous
App observers) executing inside that window.
This is exactly route 3's N3 shape
(`docs/research/2026-08-04-c4-route-3-retail-review-round2.md:346-362`):
"Refusing to fake success is right; converting a possibly-transient condition
into a process-killing throw on the host that must survive K4's 30-session /
two-hour endurance profile is the wrong end of that trade." The tripwire's own
comment asserts a diagnosis it cannot establish — "a mismatch here can only be
a producer regression" — when a parent replacement whose purge ordering ever
changed would produce the same mismatch. On the headless site the throw lands
in `OnParentUpdated`, an inbound sink; `HeadlessSessionHost.cs:52/64` will
quarantine it, i.e. one bot session of 30 dies rather than the process — still
a lost endurance row, and the torn canonical state above is what it dies
holding.
**Fix direction (cheap, no redesign).** Move the incarnation check *above*
`CommitStagedParent`/`TryCommitParent` at both sites and make it a refusal,
not a throw: log loudly (the codebase's existing refusal idiom) and return
`false`, so the staged relation is rejected (`RejectProjection`) rather than
left to block `Resolve`. That satisfies F1's pinned outcome exactly — no
silent success under a mismatched key — without a fatal, and without tearing
the transaction. If the project wants a hard assertion, it belongs in a
`Debug.Assert`/test-only seam, not on the live packet path.
---
## A2 — MAJOR — a deferred late-bind relation skips `accept`, so it carries no child-freshness gate at all, and is retained across the child's own generation boundary
**`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:506-524`** (the
`if (!lateBind)` wrapper around the `accept(update)` call) and **`:412-426`**
(`DeferCreateObjectRelation` enqueues with `WaitOwner = Parent`).
For every ParentEvent-sourced relation, `accept` is
`TryApplyParent`/`TryAcceptParentForProjection` → the child's
`PhysicsTimestampGate.TryAcceptPositionChannelEvent`
(`InboundPhysicsStateController.cs:258-273`). That call does two things: it
*gates* the relation on the child's POSITION_TS, and it *advances* the child's
gate to `update.ChildPositionSequence` — which is precisely what makes the
subsequent `TryCommitParent` gate
(`InboundPhysicsStateController.cs:299-312`: `gate.PositionTimestamp !=
positionSequence || child.PositionSequence != positionSequence`) satisfiable.
A late-bind relation skips both. Its only remaining gate is that same
`TryCommitParent` equality — against a `ChildPositionSequence` captured when
the relation was *enqueued*, possibly many packets earlier.
Compounding it: `DeleteGeneration` and `EndGeneration` both run
`FilterChildCandidates(guid, relation => relation.WaitOwner is
ParentAttachmentWaitOwner.Parent)` (`:819-821`, `:844-846`) — i.e. they
**retain** relations whose `WaitOwner` is `Parent`. `DeferCreateObjectRelation`
sets `WaitOwner = Parent` at enqueue, and `Resolve`'s re-queue would set it
anyway, so a deferred late-bind relation survives its own child's delete or
replacement.
**Failure scenario.** Child C's CreateObject/`CreateParentUpdate` defers a
late-bind relation naming parent P (P unaddressable). C is then replaced by a
new generation (`EndGeneration(C, n+1)` — an ordinary re-observe shape) or
deleted and its GUID recycled. The relation survives. P later becomes
addressable → `RetryWaitingDescendants(P)``Resolve(C, …)` → the late-bind
branch adopts P's *current* incarnation and stages, with no `accept` gate to
reject the stale `ChildPositionSequence`. Two outcomes, both bad:
- `TryCommitParent`'s POSITION_TS equality fails (the new child generation has
a different stamp) → `CommitStagedParent` returns false →
`PrepareAndTryRealize` returns `default` → **the relation is stranded in
`_stagedByChild` permanently.** `Resolve`'s early return (`:457-458`) then
blocks every subsequent legitimate parent relation for that child for the
rest of the session, and `CopyPendingProjectionChildrenTo` retries it every
frame forever.
- Or the stamps happen to coincide and the attach commits — binding a dead
wire event's relation onto a new child incarnation and whatever object now
holds P's GUID. This is AP-132's documented GUID-reuse hazard, applied on the
one producer that now has no gate at all.
Note the pre-fix behaviour self-healed: `AcceptCreateObjectRelation`
(`:391-394`) is an unconditional `_stagedByChild[child] = relation`
assignment, so a newer relation always superseded a stuck one. Routing through
the queue removes that self-healing property.
**Reachability caveat, stated honestly:** see A6 — the deferred branch appears
to be production-unreachable today, so this is latent, not live. It is still a
MAJOR defect in newly added code, because the code's stated purpose is to be
the fail-safe.
**Fix direction.** Either (a) subject late-bind relations to the same child
POSITION_TS gate (call `accept` and let it advance the stamp — the retail
argument for skipping it is about the *parent* incarnation, not the *child*
timestamp), or (b) if the branch stays as a pure fail-safe, do not enqueue it
with `WaitOwner = Parent` and add an explicit child-incarnation field so the
generation filters can drop it with its child.
---
## A3 — MEDIUM — the deferred relation carries a placeholder `ParentInstanceSequence = 0` that the generation filters read as a wire-named incarnation
**`src/AcDream.App/Rendering/EquippedChildRenderController.cs:838-844`** builds
the relation with the literal `ParentInstanceSequence: 0` and hands that exact
value to `DeferCreateObjectRelation` when the parent is unknown. The
`LateBindParentInstance` flag records "this 0 is meaningless" — but only
`Resolve` reads the flag.
`EndGeneration` (`ParentAttachmentState.cs:828-833`) and `DeleteGeneration`
(`:853-857`) both filter the unresolved queue with predicates that compare
`relation.ParentInstanceSequence` against a real generation via
`PhysicsTimestampGate.IsNewer`. For a late-bind relation with the placeholder
0 and a *player* parent (`TotalLogins` ≥ 1, never 0):
- `EndGeneration(P, 8)`: `0 == 8` false; `IsNewer(8, 0)` false → **dropped.**
- `DeleteGeneration(P, 7)`: `IsNewer(7, 0)` false → **dropped.**
**Failure scenario.** A late-bind relation whose semantic is "attach to
whatever currently holds this GUID" is judged as "names generation 0, which is
older than the replacement" and silently discarded at exactly the moment the
replacement it should bind to arrives. Player-class only — #319's own failure
signature, reintroduced in a narrower window.
Not reachable today only because a `NewGeneration` disposition requires a
pre-existing snapshot, and a pre-existing snapshot means the producer would
have taken the *staged* branch rather than deferring. That coupling is
undocumented and is not a property either file states.
**Fix direction.** Make `FilterParentCandidates`'s callers retain
`LateBindParentInstance` relations unconditionally (they name no generation to
compare), or carry a sentinel the filters can recognise.
---
## A4 — MEDIUM — the `RestoreShadow` gate is *not* behaviour-preserving at HEAD; contract §3.2's premise is wrong for creature-parented children
**`src/AcDream.App/World/LiveEntityPresentationController.cs:229`**
The contract argues (§3.2) that `RestoreShadow` "no-ops today on
`record.FullCellId == 0`", so the new `HasCommittedParent` clause is inert at
HEAD and only matters post-fix. **That premise holds only for
player-parented children.** Route 7's D1 already re-cells *creature*-parented
children to a nonzero cell — that is exactly the class route 7 shipped and
gated. So today an NPC's wielded weapon that has a shadow registration and
crosses a Hidden→Visible edge reaches `ShadowPositionSynchronizer.Sync` and
gets its broadphase row refreshed; with this gate it no longer does.
The change is in the *right* direction — route 7's P4 record
(`RuntimeEntityDirectory.cs:451-465`) says a committed child never owns a
broadphase row, and `ShadowObjectRegistry.UpdatePosition`
(`ShadowObjectRegistry.cs:696-697`) no-ops for an unregistered entity, so the
live population is bounded by "children that already carry a registration."
But this is a live behaviour change on a shipped path, justified in the
contract by a premise that does not hold, and route 7's connected gate never
exercised it.
**Failure scenario if the premise is wrong in the other direction:** a
formerly-world object that is picked up and equipped keeps a suspended
registration in `_shadows` that `RestoreShadow` will now never restore, while
`_suspendedShadowOwners` keeps its key (`:203` is only reached when `restored`
is true). `Forget`/`Clear` (`:111-127`) converge it at teardown, so this is
bounded — but it is a state the contract did not analyse because it assumed
the population was empty.
**Fix direction.** Not a code change necessarily — but the creature-parented
half must be named in the connected gate (§7 Half B) as a thing to watch, and
the contract's §3.2 "correct today" claim corrected. The test
`CommittedChild_HiddenThenVisible_NeverInstallsShadowRow` does assert against
the real `ShadowObjectRegistry` (the fixture pre-registers the entity at
`LiveEntityPresentationControllerTests.cs:700-710`), so the AP-145/#318 rule
is honoured — good.
---
## A5 — MEDIUM — unbounded accumulation of deferred late-bind relations (the un-written §6 test 9 would have caught this)
`_unresolvedByChild` is removed for a child only by `RemoveChild`/`RemoveObject`
(`ParentAttachmentState.cs:782`, `:878`) or by `Clear()` (`:890`), and per
parent guid by `RemoveObject(parentGuid)` (`:788-798`) and the two generation
filters. A late-bind relation naming a parent GUID that **never becomes
addressable and is never itself deleted** (an equip whose holder stays outside
visibility) is removed by none of them and is retained across the child's own
delete by the `WaitOwner is Parent` rule (A2).
Each such relation is a permanent `+1` on `PendingRelationCount` and a
permanent per-parent-spawn scan cost in `ChildrenWaitingForParent`
(`:715-719`, a `queue.Any(lambda)` per child). Over a two-hour, 30-session
endurance run this accumulates monotonically. `Clear()` converges it at reset,
so a teardown-only ledger assertion would pass — which is exactly why the
missing §6 test 9 is not the whole answer here.
**Fix direction.** Bound the unresolved queue per child (the existing
`Enqueue` has the same shape, so this is a pre-existing class the new producer
widens), or age deferred late-bind relations out.
---
## A6 — MEDIUM — the deferred branch is production-unreachable for *both* producers, and its only test reaches it by bypassing the production seam under an incorrect stated rationale
Implementer claim 1 is **verified** for the raw CreateObject path:
`RuntimeEntityObjectLifetime.cs:797-812` defers the whole CreateObject when
`beginInitialResidence && parentGuid != 0u && !Entities.TryGetActive(parentGuid, out _)`,
and `TryGetActive``TryGetSnapshot` (the snapshot is written by
`AcceptCreate` before `AddActive`). So `OnSpawn` never sees an unaddressable
parent.
**But the same gate covers `CreateParentUpdate`.** The test's own justification
(`EquippedChildProjectionWithdrawalTests.cs`, the doc comment on
`OnCreateParentAccepted_ParentNotYetKnown_DefersThenLateBindsOnArrival`) says
`CreateParentUpdate`'s producer "has no equivalent parent-addressability
precondition." That is wrong: `CreateParentUpdate` is manufactured by
`BuildSameGenerationEvents` inside the very `RegisterEntityCore` call whose
line-797 gate reads `incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid`
a same-generation CreateObject carrying a Parent hits the identical deferral.
The test reaches the deferred branch only because it calls
`fixture.Live.TryApplyCreateParent(...)` and `fixture.Controller.OnCreateParentAccepted(...)`
directly, below the routing layer that would have prevented it.
So `DeferCreateObjectRelation`, the `LateBindParentInstance` field, and
`Resolve`'s late-bind branch — roughly half of F1's added lines and all of its
novel state — are **dead in production**, and they carry A2's freshness hole
and A3's placeholder-sequence hazard. A fail-safe that resurrects relations
across generation boundaries with no timestamp gate is not a safer state than
the assertion it replaces.
**Fix direction.** Either establish a real production path (and then test it
through the production seam), or reduce the branch to something with no
independent failure modes — e.g. refuse the relation outright with a loud log
when the parent is unaddressable, which is what the layer above already
guarantees cannot happen.
---
## A7 — LOW — the hydration gate is correctly scoped
**`src/AcDream.App/World/LiveEntityHydrationController.cs:561-562`** — verified
behaviour-preserving at HEAD and necessary post-fix, on stronger grounds than
the contract gave:
- `LiveEntityRecord.ProjectionCellId` (`LiveEntityRuntime.cs:387-389`) is
`WorldEntity is not null ? FullCellId : Snapshot.Position?.LandblockId ??
FullCellId`. A committed child always has a null snapshot `Position`
(`ApplyParent` nulls it, `InboundPhysicsStateController.cs:1358/1366`), so
all three sources of `projectionCellId` collapse to `FullCellId` — 0 today,
the parent's cell post-fix. The gate is therefore exactly a no-op at HEAD
for both parent classes and exactly required after.
- The `continue` skips all three downstream branches (`CreateSupersessionRecovery`,
`RebucketLiveEntity`, `SpatialRecovery`), which is correct: none of them was
reachable for this population before, so nothing legitimate is newly dropped.
- The predicate matches the precedent it cites
(`RuntimeSetPositionState.IsAffectedCollisionResident`), and stale committed
entries for a replaced child are cleared by `RemoveCommittedChild` inside
both generation paths.
No finding beyond noting the gate is GUID-keyed while the loop iterates
canonical records; that is safe today only because `_lastAcceptedByChild` is
purged on every child generation change.
---
## A8 — LOW — `AcceptCreateObjectRelation` remains a public, unguarded producer
`ParentAttachmentState.cs:391-394` still accepts any `ParentInstanceSequence`
and is called raw from twelve test sites. The tripwire is the only thing
standing between a future caller and #319 verbatim — and per A1 that tripwire
is optional (`resolveParentInstance` defaults to `null`) and fires after the
canonical commit. Consider making the parameter required, or making the
late-bound wrapper the only public entry.
---
## Implementer claims — adjudicated
| claim | verdict |
|---|---|
| 1. `OnSpawn`'s deferred branch is structurally unreachable in production | **VERIFIED** (`RuntimeEntityObjectLifetime.cs:797-812`). But it is unreachable for the `CreateParentUpdate` producer too, which the diff and its test both deny — see A6. Not acceptable as an untested fail-safe in its current shape. |
| 2. Sabotage: restoring the literal `0` fails the player row via the tripwire before the value assertion | **CONCERN REFUTED.** With the tripwire removed and the literal `0` restored, `CommitProjection` returns true and files `(parentGuid, 0)`, so `Assert.Equal(parentIncarnation, committedInstance)` fails on its own; the D1 assertion (`childCanonical.FullCellId == parent.Canonical.FullCellId`) and the D2 assertion also fail independently, because `CommitAcceptedParentCellless`'s `parent.Incarnation == parentInstanceSequence` gate and `ChildrenAttachedToParent(guid, Incarnation)` both miss. The matrix proves what it claims. |
| 3. 91 added / 24 removed, net +67 vs a 3580 estimate | **VERIFIED exactly.** The overshoot is entirely the tripwire plumbing plus the second commit site — i.e. the two things A1 says should be restructured. |
## §6 gaps — do they block?
- **Test 5 (residence non-event)****does not block.** Contract §3.3's premise
is verified at `RuntimeEntityObjectLifetime.cs:2751-2752`
(`if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u, 0u)`
immediately before `InitialCreateResidences.Begin`), so `Begin`'s
`FullCellId != 0` refusal is genuinely unreachable and no production code on
that path changed.
- **Test 6 (unwield classification population)****does not block.** No
production code on the classifier path changed, and the contract already
recorded (`af828a8a`) that the short-circuit OR at
`RuntimeAuthoritativePositionRouteClassifier.cs:391` labels the drop
`teleport-ts` either way, so the observable outcome is unchanged.
- **Test 9 (ledger convergence)****BLOCKS.** It is the one missing test whose
subject is exactly where the new state lives, and A2/A5 are precisely
ledger-convergence defects: a late-bind relation that outlives its child's
generation, blocks `Resolve` for that child forever, and accumulates in
`_unresolvedByChild` with no bound. A convergence test written against the
new deferred population (child deleted with a deferred relation pending;
parent guid never arriving; teardown *and mid-session* counts) would have
surfaced both.
## Route 7 invariants
Re-checked against the diff and found intact: removal propagates ZERO
(untouched; a new dual-parent-class withdrawal test was added);
`RebucketLiveEntityPresentationOnly` is not made a canonical writer (the D4
demotion stands, `LiveEntityRuntime.cs:1033-1060` untouched); no per-tick
cross-cell rebuild is introduced; no new `SetFullCell` call site exists; the
ParentEvent path's AP-132 incarnation gating is preserved verbatim in
`Resolve`'s `else if` arm.
## What a PASS would need
1. **A1** — move the incarnation check above the canonical commit at both sites
and make it a logged refusal returning `false` (rejecting the staged
relation), not a throw.
2. **A2/A3/A6** — either give the deferred branch a real production path and a
test that reaches it through production routing, or reduce it to a loud
refusal with no independent state. If it stays, close the missing child
freshness gate and the placeholder-sequence filter hazard.
3. **Test 9** — a ledger-convergence test over the new deferred population,
asserting both mid-session boundedness and teardown convergence.
4. **A4** — correct the contract's §3.2 premise and name the creature-parented
shadow behaviour change in the connected gate's Half B watch list.
Nothing here requires redesign; items 1 and 3 are small, item 2 is a scoping
decision.

View file

@ -0,0 +1,674 @@
# Issue #319 contract — the player-parented child's canonical cell (2026-08-05)
**Scope:** fix the hard-coded `ParentInstanceSequence: 0` that files every
CreateObject-carried parent relation under the wrong key for player parents
(`EquippedChildRenderController.OnSpawn`), restore route 7's D1/D2 propagation
for player-parented children (local AND remote players), structurally gate the
two call sites whose inertness today is an accident of the zero cell, and pin
the child's source of truth for a client-authoritative parent. **This slice
does NOT make the local player's canonical cell track ordinary movement** —
that is Decision 1's call, argued in §2, filed as its own follow-up.
Pinned at HEAD **`af828a8a`**, clean tree, branch
`claude/acdream-physics-divergence-5aa784`. (The dispatch named `2687d893`;
the branch advanced one docs commit — `af828a8a`, the gate-4 probe-label
closure — before this contract was written. Nothing in that commit changes
this contract's premises; it strengthens §6's probe rule.) **Line numbers are
as-of `af828a8a` and WILL go stale; every citation also names the symbol —
trust the symbol.**
Predecessor documents, binding where they still apply:
- [`2026-08-05-local-player-child-propagation.md`](2026-08-05-local-player-child-propagation.md)
**the settling investigation. Its §§16 evidence is BINDING**; this
contract re-verified its load-bearing claims at HEAD and confirms them
(one nuance on §5.1 item 3, resolved in §3.3 below).
- [`2026-08-04-c4-route-7-contract.md`](2026-08-04-c4-route-7-contract.md)
— the regressing route's contract. Its §3 "must REMAIN true" invariants
all still bind and are re-asserted in §5; its D1/D2/D3/D4 designs are NOT
re-opened.
- [`2026-08-05-c4-closeout-handoff.md`](2026-08-05-c4-closeout-handoff.md)
— carries the CORRECTED route-7 gate criterion (positive equality
assertion, dual parent class). §7 of this contract instantiates it.
- [`2026-08-04-retail-parent-cell-propagation.md`](2026-08-04-retail-parent-cell-propagation.md)
— retail `set_parent`/`enter_cell` recursion, settled. Not re-derived.
- `docs/ISSUES.md` #319 — the defect record. This contract does not restate
its root-cause chain; it builds on it.
---
## 0. The defect, established — binding, do not re-derive
`EquippedChildRenderController.cs:134` (`OnSpawn`) hardcodes
`ParentInstanceSequence: 0` into `Relations.AcceptCreateObjectRelation`.
Correct for creatures/statics (genuinely sequence 0); wrong for players,
whose `ObjectInstance` is `Character.TotalLogins` (ACE
`Player_Networking.cs:34-37`), parsed into `RuntimeEntityRecord.Incarnation`
(`CreateObject.cs:749``WorldSession.cs:233`
`RuntimeEntityRecord.cs:43`). The relation files under `(playerGuid, 0)`
while the record carries `TotalLogins`, so both route-7 write sites miss:
- **D1 attach re-cell**`RuntimeEntityObjectLifetime.CommitAcceptedParentCellless`'s
`parent.Incarnation == parentInstanceSequence` gate (`:1537`) → false.
- **D2 crossing propagation**`RuntimeEntityDirectory.PropagateFullCellToChildren`'s
`ChildrenAttachedToParent(guid, current.Incarnation)` (`:478-480`) →
`Array.Empty<uint>()`, forever.
`TryCommitParent` never validates the sequence; the attach succeeds
silently. Scope: the local player and every remote player, for every
CreateObject-carried equip (i.e. every login and every first-observe).
User-visible consequence NIL — verified in the investigation §5, not
assumed. `ParentEvent`-carried relations (mid-session equip) are unaffected:
`ParentAttachmentState.Enqueue`/`Resolve` carry and validate the wire's real
sequence (`ParentAttachmentState.cs:396-411`, `:440-454`).
---
## 1. Retail ground truth — does retail distinguish a player parent?
**No. The player/non-player split is entirely an acdream artifact of the
(guid, incarnation) relation key meeting a wire message that carries no
parent instance sequence.** Verified for this contract, not inherited:
| claim | anchor | status |
|---|---|---|
| The CreateObject attach path looks the parent up **by GUID alone** in the live object table and calls `set_parent` — no instance-sequence read anywhere in the attach | `ACCObjectMaint::CreateObject` @0x00558870: `PhysicsDesc::get_parent_id` @0x00558a18, `CObjectMaint::GetObjectA(this, parent_id)` @0x00558a2d, `CPhysicsObj::set_parent(result, arg2, location)` @0x00558a3e | ✓ read at the pseudo-C |
| The reverse direction (parent's CreateObject naming its children) is the same shape: per-child hash lookup by GUID, `GetNullObject` **placeholder** when the child is not yet constructed, then `set_parent` — again no instance gate | `CObjectMaint::SetChildren` @0x00509370: hash walk @0x005093b2-0x005093ca, `GetNullObject` @0x005093e6, `set_parent` @0x005093f8 | ✓ read at the pseudo-C |
| `set_parent` / `enter_cell` / `leave_cell` / `change_cell` contain no player test of any kind — they operate on `CPhysicsObj*` uniformly | `set_parent` @0x00515A90 (order enumerated in route-7 contract §2 row 3); `enter_cell` @0x00510ed0; `leave_cell` @0x00510f50; `change_cell` @0x00513390 | ✓ (route-7 contract §2, re-affirmed; no player branch exists in any of the four bodies) |
| Retail's `ObjectInstance` sequence lives in the physics-descriptor timestamp block and gates **message staleness**, never the parent relation — the relation is by live object pointer | timestamp block slot 8; AP-132's retail half ("retail's queue-by-GUID replay is pointer-only", @0x004535D0 ~92310-92326, `QueueBlobForObject` @0x005092D0) | ✓ (AP-132, register) |
| Retail's player cell is NEVER stale: `SetPositionInternal` writes the player's own cell on every physics tick, so "parent cell propagation from a stale parent cell" is unrepresentable in retail | `CPhysicsObj::SetPositionInternal` @0x00515330 (propagation research, binding) | ✓ |
| **CORRECTION (retail-conformance review, 2026-08-05): this row understated its own anchor.** `SetPositionInternal` @0x00515330 does not merely write the mover's own cell — in the same-cell branch it writes `this->m_position.objcell_id = objcell_id` @0x00515385, THEN walks `this->children` and writes each child's cell id directly (`*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD, `CPartArray::SetCellID` @0x005153CC, looping @0x005153AE@0x005153D8); the cross-cell branch delegates to `change_cell` @0x00515372. Retail's D2 equivalent (per-tick child cell propagation) lives INSIDE THE SAME FUNCTION as the player's own per-tick cell write — a strengthening of the equality invariant and of AP-146, not a contradiction. (AP-142 clause (b) already cited this range; only this table's summary understated it.) | same, ranges above | ✓ |
Consequences, pinned:
1. **The retail-faithful semantic for a CreateObject-carried relation is
"attach to the current holder of the parent GUID"** — late-bound, no
wire incarnation to honor, because the wire supplies none. acdream's
mapping: the relation adopts the parent's **live incarnation at
resolve/commit time**. This is NOT a weakening of AP-132's incarnation
posture: AP-132 gates relations whose wire event **names** a specific
parent incarnation (`ParentEvent.ParentInstanceSequence`); a CreateObject
relation names none, so adopting the live value honors exactly what the
server sent — a GUID. AP-132's row gains a clarifying sentence in the
fix commit (§4 F4).
2. **The local player's coarse canonical cell is an acdream divergence with
no register row.** Retail writes the player's cell per tick; acdream's
canonical `FullCellId` for the local player is written only at login
activation (`RuntimeSetPositionState.cs:2741-2745`), accepted inbound
Position/ForcePosition (`RuntimeEntityDirectory.RefreshSnapshot`
`RuntimeEntityRecord.cs:234`), and teleport/portal commit
(`RuntimeSetPositionState.cs:5001-5007`;
`LocalPlayerTeleportController.cs:255`). Ordinary WASD passes a
landblock id (`LocalPlayerProjectionController.Project`, `:85-98`, low
16 bits forced to `0xFFFF` in BOTH branches) that
`LiveEntityRuntime.RebucketLiveEntity` explicitly preserves the old cell
for (`:935-938`). Register rule 1 applies: **this divergence gets its
row in the fix commit regardless of Decision 1's direction** (§4 F4).
---
## 2. DECISION 1 — the child follows the parent's CANONICAL cell; the key fix alone is correct and complete for #319; the player-cell-tracking question is real, pre-existing, and files separately
**The call is (c)** — neither (a) nor (b) as framed. (b)'s framing
("staleness is harmless everywhere") is false without the §3 gates; (a)'s
framing ("stale is strictly worse than zero, so #319 must make the player's
cell track") rests on an argument that dissolves under the consumer
enumeration below. The pinned design:
> **The committed child's canonical `FullCellId` EQUALS its committed
> parent's canonical `FullCellId` at every stable observation point — no
> more, no less.** That is route 7's §3.1 headline invariant verbatim, and
> it is an EQUALITY invariant, not a freshness invariant. The parent's own
> canonical-cell freshness is a property of the PARENT's record, owned by
> the parent's own write paths — pre-existing, unchanged by this fix, and
> documented as its own divergence (§1 item 2). #319's fix makes the child
> inherit the parent's value exactly; it does not, and must not, invent a
> different cell authority for the child than the parent itself has.
### 2.1 Why (a)'s "stale defeats 45+ liveness sites" argument fails for the child
The 45+ `FullCellId != 0` predicates (route-7 contract §0 item 6) read a
**spatial root's** cell. A committed child is structurally excluded from
every one of them by a NON-cell clause, verified at HEAD:
- `LiveEntityRuntime.GetRootObjectClockDisposition` (`:2602-2612`) and
`HasSpatialRuntimeProjection` (`:3340-3345`): both require
`ProjectionKind is LiveEntityProjectionKind.World`; a committed child is
`Attached` — excluded on the first clause regardless of cell value.
- The collision-retirement sweep `RuntimeSetPositionState.IsAffectedCollisionResident`
(`:3930-3946`): triple-gated — spatial-roots-only iteration
(`ParkCollisionResidents` `:3749-3756`), `_physics.IsSpatialRoot`
(`:3942`), and an explicit
`!ParentAttachments.HasCommittedParent(record.ServerGuid)` (`:3944-3945`).
A committed child can never be parked by a retiring prefix, at any cell
value.
- Route 7's own P4 record (`RuntimeEntityDirectory.cs:451-465`): a committed
child is never a spatial root, joins no workset, has no shadow row.
What remains is exactly four consumers that can read a CHILD's cell, and
each is resolved individually:
1. **`LiveRenderProjectionJournal.Project` (`:270-277`)** — prefers
`record.FullCellId` over the `entity.ParentCellId` fallback when nonzero.
Traced to its consumers at HEAD: the dynamics **draw visibility** routes
read `record.Source.ParentCellId` (the fresh, TickChild-maintained
presentation field) — `RenderScenePViewFrameProduct.BuildDynamicLastRoute`
(`:1441-1481`, `ParentCell(in record)` for the indoor
`SphereVisibleInCell` test) and `BuildOutsideDynamicRoutes` (`:1356-1361`,
same source). `Residency.FullCellId` feeds only (i) the indoor per-cell
candidate index `_cellDynamics` (`ArchRenderScene.AddToIndices` `:639`),
consumed by the look-in candidate enumeration
(`BuildLookInRoutes``LoadCell` `includeDynamics: true`, `:1319-1322`),
and (ii) the `RenderSceneShadowRuntime` residency assertion (`:403`).
**Decisive fact: the LOCAL PLAYER's own record already sits in this exact
index under this exact staleness today** — the player's `FullCellId` is
nonzero-stale during ordinary play, and the player renders correctly
everywhere, because visibility is `Source.ParentCellId`-driven. Post-fix
the child is filed **beside its parent, under the same value** — equality
with the parent in the render residency index is the consistent state,
not a new staleness class. (P-R1 in §6 pins this with the shadow-runtime
assertion in mind.)
2. **The hydration filter** (`LiveEntityHydrationController.OnLandblockLoaded`
`:551-557`) — wakes undesirably; gated structurally in §3.1.
3. **`RestoreShadow`** (`LiveEntityPresentationController.cs:216-236`) —
wakes undesirably; gated structurally in §3.2.
4. **The unwield/drop Position classification**
(route-7 contract §11; `RuntimeAuthoritativePositionRouteClassifier`) —
wakes DESIRABLY: a committed child's pre-merge cell becomes
"deterministically the parent's (nonzero whenever the parent is
celled)", which is **route 7 §11's own stated intent and retail's own
predicate population** (retail's `unset_parent` does no cell work, so a
wielded child's unwield Position reaches `MoveOrTeleport` with the
parent's nonzero cell). #319 had silently re-created the pre-route-7
cell-less population for player-parented children; the fix restores the
§11 direction. Note the `af828a8a` finding: at a drop ACE also advances
TELEPORT_TS and the classifier is a short-circuit OR
(`RuntimeAuthoritativePositionRouteClassifier.cs:391`), so the observed
label stays `teleport-ts` either way. One test pins the stale-cell
flavor (§6 test 6).
Additionally, `RuntimeTraceRecorder.OnEntity`
(`GameRuntimeEvents.cs:246-253`) stops recording `0` for the player's
weapon — a diagnostic improvement, not a risk.
**Conclusion:** with the two §3 gates in place, no consumer of the CHILD's
cell behaves worse with stale-but-equal than with zero, several behave
better, and the route-7 invariant is restored exactly. The (a) framing's
"wrong answer looks right" applies to the PARENT's record — which this fix
does not touch and which is exactly as stale before and after.
### 2.2 Why the player-cell-tracking half does NOT ride in #319
Making `LocalPlayerProjectionController.Project` pass the exact
`movement.CellId` (available in both branches) instead of the coarsened
landblock would touch, at minimum:
- the deliberate landblock-preserve contract at
`LiveEntityRuntime.cs:935-938` (a generic rebucket rule, not
player-specific — changing its input population changes it for the one
caller that relies on it);
- `Rebucketed` delta publication cadence: today the player NEVER publishes
a `Rebucketed` entity delta during WASD (the preserve path early-outs at
`CommitRebucket`'s `previous == fullCellId`, `RuntimeEntityObjectLifetime.cs:1965-1972`);
exact-cell commits would publish per crossing (EnvCell crossings are the
high-frequency case) — a consumer enumeration in route 7's P8 class;
- the accepted-Position classification inputs for the LOCAL player (route
2 and the 4b-3 `PreMergeCommittedCellId` measurement at
`TryApplyPosition:1801-1814`): a fresh committed cell changes the
pre-merge population on live correction paths that AP-136/AP-138 spent
four review rounds pinning;
- the portal-space freeze interaction
(`LocalPlayerProjectionController.Project:100-103` — "the teleport owner
alone projects the destination while the local controller deliberately
retains its frozen source cell"): a canonical exact-cell writer must not
race the teleport owner;
- the `isOrdinaryRoot` family (`LiveEntityRuntime.cs:915-918`, `:3213`,
`:3323`) and the animation-scheduler local-player exclusion
(`LiveEntityAnimationScheduler.cs:183-227`).
That is a local-player movement-spine slice with its own contract, its own
review, and its own gate — calibrated against the campaign, comfortably
larger than route 7's 127 production lines once its verification surface is
counted, for zero behavioral need identified today. **It files as a
follow-up issue in the fix commit** (§4 F4), carrying one specific hazard
this contract identified and could NOT close (§9 item 2): whether the local
player is a `RuntimePhysicsState` spatial root, and if so whether a
stale-cell landblock retirement (a player WASD-ing beyond the streaming
radius from its last teleport, with no intervening teleport or inbound
Position) can sweep the player into `ParkCollisionResidents`. The child is
provably immune (the `HasCommittedParent` gate); the player is not obviously
so, and the connected routes that pass today teleport between stops, which
refreshes the cell and may be masking it.
---
## 3. DECISION 2 — the three inert-because-zero sites, one verdict each
### 3.1 Hydration filter — WAKES WRONGLY; add the structural gate in this slice
`LiveEntityHydrationController.OnLandblockLoaded` computes
`projectionCellId = projection?.ProjectionCellId ?? Snapshot.Position?.LandblockId ?? candidate.FullCellId`
(`:551-553`) and admits candidates on `projectionCellId != 0` + landblock
match + `SetupTableId` (`:554-557`). A committed child today falls through
all three sources to `0` (its `ProjectionCellId` is unset, its
`Snapshot.Position` is null per route-7 §0 item 10) and is skipped. With a
nonzero canonical cell it becomes a candidate whenever its (= the parent's)
canonical landblock loads — e.g. at every login, where the equipment's cell
names the activation landblock — and takes the full legacy
`RebucketLiveEntity` branch (`:594-596`), which writes
`entity.ParentCellId` (`LiveEntityRuntime.cs:885-898`) AND reaches
`CommitRebucket` — **a second canonical cell writer for a child, the exact
two-writer defect route 7 exists to remove**, plus a one-frame presentation
overwrite that TickChild then repairs.
**Waking is not desirable, and the current inertness is an accident of the
bug.** Pinned: the candidate loop excludes records with a committed parent
— gate on `ParentAttachments.HasCommittedParent(candidate.ServerGuid)` (or
the equivalent projection-kind test), the same structural exclusion the
retirement sweep already uses (`RuntimeSetPositionState.cs:3944-3945`).
Rationale is retail-anchored, not defensive: a retail child is never
independently re-placed by cell load — `update_object`'s `parent != 0`
early-out (@0x00515D40) means the parent's own propagation is the only
mechanism, and acdream's analog (D2) needs no hydration assist. The gate is
correct TODAY (it changes nothing for a zero-cell child) and required
post-fix; it lands in the same commit as the key fix, before it in
sequence.
**CORRECTION (retail review round 2, D1, 2026-08-05): the "falls through to 0"
and "correct TODAY (changes nothing for a zero-cell child)" claims above are
true only for a PLAYER-class parent — false for a creature/static-class
parent.** Pre-fix, `EquippedChildRenderController.OnSpawn`'s hardcoded
`ParentInstanceSequence: 0` genuinely MATCHES a creature-class parent's real
incarnation (creatures/statics are sequence 0), so D1's
`parent.Incarnation == parentInstanceSequence` gate already passes for that
class at HEAD, the relation commits, and the child already carries a nonzero
canonical `FullCellId` pre-fix — meaning a creature-parented child is ALREADY
a hydration candidate today and already takes the legacy `RebucketLiveEntity`
branch this gate removes. §3.2's twin premise received this same correction
(architecture review A4); this paragraph did not, until now. The gate's
direction is still correct (route 7's P4/D4), and it is still required in
this slice — but it is a live behaviour change for the creature class, not
merely a post-fix necessity for the player class. See the §7 gate's Half B
for the connected-gate watch step this requires.
### 3.2 `RestoreShadow` — WAKES WRONGLY; add the structural gate in this slice
`LiveEntityPresentationController.RestoreShadow` (`:216-236`) no-ops today
on `record.FullCellId == 0` (`:220`). Its other two guards do NOT protect a
committed child: `IsSpatiallyProjected` is set `true` by the
presentation-only rebucket the child takes every frame
(`LiveEntityRuntime.cs:1043`, inside `RebucketLiveEntityPresentationOnly`),
and `IsSpatiallyVisible` follows the child's ordinary visibility. On a
Hidden→Visible edge (`RetailHiddenTransition.BecameVisible`, `:187-204`
reachable for a child both via its own transition and via the parent's
unhide cascading through `_setDirectChildrenNoDraw`), a nonzero cell would
install a `ShadowObjects` broadphase row for an equipped weapon at the
parent's canonical cell — contradicting route 7's P4 record ("a committed
child never joins a workset or shadow list",
`RuntimeEntityDirectory.cs:451-465`), AP-142's model, and retail (a child's
broadphase state is established at attach/unparent edges only; route-7
contract §0 trap 5a). An invisible-but-solid weapon row at a possibly-stale
cell is the #184 shape.
**Pinned:** `RestoreShadow` refuses records with a committed parent (same
predicate as §3.1; checking `ProjectionKind` alone is acceptable if the
implementer shows it is equivalent for every reachable record). Same
commit, same rationale: correct today, required post-fix.
### 3.3 `RuntimeInitialCreateResidenceState.Begin` — waking is a NON-EVENT; verify with a test, no production change
The investigation flagged `Begin`'s `record.FullCellId != 0u` refusal
(`:583`) and `TryConvertToCellessRoute`'s (`:1041`) as unknowns. Resolved
at HEAD: **the refusal is a defensive invariant, not a reachable gate.**
`Begin`'s only production caller,
`RuntimeEntityObjectLifetime.InitializeAcceptedCreateResidence`
(`:2736-2771`), ZEROES a nonzero cell first — `:2751-2752`,
`if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u, 0u)`
— before calling `Begin`. So a re-CreateObject that opens a fresh residence
on a record inheriting a nonzero child cell zeroes it (flowing through the
D2 chokepoint, correctly zeroing any grandchildren per AP-142 clause (a)),
opens the `Parented` residence, and D1 re-cells at the attach commit —
the same sequence a first create runs. `TryConvertToCellessRoute`'s refusal
concerns an OPEN lease on a record with a committed cell — for a committed
child the lease was already forgotten at the attach commit's cancellation
prefix, so the arm is unreachable for the waking population.
**Pinned:** no production change at this site. One test (§6 test 5) drives
a re-CreateObject against an attached, non-zero-cell child (same
incarnation → the `TryApplyAttachedAppearance` path; new incarnation → the
replacement path with `InitializeAcceptedCreateResidence`) and asserts
byte-identical outcomes to today's, plus the zero-then-re-cell sequence
staying inside the transaction (no observable cell-less escape — route 7's
D1 atomicity clause).
---
## 4. The fix, pinned
### F1 — the key: CreateObject-carried relations late-bind to the parent's live incarnation
`EquippedChildRenderController.OnSpawn` stops writing the literal `0`:
- **Parent snapshot known at accept time** → stage the relation with the
parent's live `InstanceSequence` — the identical lookup
`ResolveRelations` already hands to `Resolve`
(`EquippedChildRenderController.cs:834-836`,
`_liveEntities.TryGetSnapshot`).
- **Parent not yet known** → do NOT stage under a guessed key. Route the
relation through the existing unresolved/deferred machinery (the
`RetryWaitingDescendants` / `WaitOwner.Parent` shape) so it resolves when
the parent arrives, adopting the parent's incarnation THEN. Retail
anchor for the late-bind semantic: `GetObjectA`/`GetNullObject` by GUID
(§1) — the wire named a GUID, not an incarnation; matching "the current
holder" is the faithful mapping. The implementer chooses the mechanism
(a wildcard-incarnation relation resolved in `Resolve`, or deferring the
`AcceptCreateObjectRelation` call itself); the pinned constraints:
**no relation is ever staged or committed with an incarnation that does
not equal the parent's live incarnation at that moment, and the
`Resolve` early-return on staged relations
(`ParentAttachmentState.cs:425-426`) must not strand a deferred
CreateObject relation.**
- **The commit-time tripwire** (the investigation's own recommendation,
adopted): the parent-relation commit path asserts
`relation.ParentInstanceSequence == parentRecord.Incarnation` whenever
the parent is active — a mismatch REFUSES the commit loudly (throw or
logged refusal per the codebase's commit-refusal idiom, implementer's
choice — pinned outcome: never a silent success under a mismatched key).
This is what converts any future producer regression from "silently
inert propagation" into a failing test/session.
This one change fixes the local player's login equipment AND every remote
player's observed equipment (same producer). `ParentEvent`-path behavior is
untouched.
### F2 — the two structural gates (§3.1, §3.2)
Both are committed-parent exclusions at sites that must never handle an
attached child. Both are behavior-preserving at HEAD (the excluded
population currently reaches neither site's active arm) and land in the
same commit so the key fix never ships without them. Sabotage rule: each
gate's test must fail with the gate deleted AND the key fix present (§6
tests 34).
### F3 — probe truthfulness
`ACDREAM_PROBE_CHILD_CELL` stays as-is (it is TEMPORARY, C5c strips it),
but the fix commit re-runs the §7 gate expectations against it. No probe
code change is required for this slice: the corrected gate criterion is a
positive read of the child's cell, not a probe-line count.
### F4 — bookkeeping, in the fix commit (register rules 12 binding)
- **AP-142 gains a clause** (or a new AP row if the reviewer prefers —
implementer's call, one of the two): the CreateObject-carried relation
late-binds to the parent's live incarnation because the wire carries no
parent instance sequence; retail's attach is GUID-only
(`GetObjectA` @0x00558a2d, `SetChildren` @0x005093f8, `GetNullObject`
placeholder @0x005093e6). AP-132's row gains the clarifying sentence
distinguishing the two producers (ParentEvent = wire-named incarnation,
gated; CreateObject = no wire incarnation, late-bound).
- **NEW register row — the local player's coarse canonical cell** (§1 item
2): intentional-architecture-or-stopgap classification to be argued in
the row itself; anchors `CPhysicsObj::SetPositionInternal` @0x00515330
vs the three acdream writers; risk column names the §2.2 park-sweep
question and the child-inherits-the-coarseness consequence (the child's
cell is stale-but-equal wherever the parent's is).
- **Follow-up issue filed**: local-player canonical cell tracking —
carrying §2.2's enumeration and §9 item 2's unresolved spatial-root
question as its first verification step.
- **ISSUES.md**: #319 → Recently closed with the commit SHA; the entry's
"do NOT rush the fix" block is answered by this contract's §2/§3.
- **Route-7 contract**: append a dated supersession note under its §7 gate
(the criterion correction is already recorded in the closeout handoff;
the note points here).
- **AP-142 clause (b)/(c) untouched**; no row deletions.
---
## 5. What must REMAIN true (route 7 §3, re-asserted for this slice)
1. Route 7 invariants 113 hold verbatim. Specifically re-tested here:
equality invariant (1), child never self-simulating (2), no placement
machinery on the route (3), **no `ConstrainTo`** (4), `TryCommitParent`
keeps zero `LeaveWorld` calls (6), presentation advances and is
asserted (7), **no per-crossing child shadow/cross-cell rebuild** (8),
ledger convergence (13).
2. **The removal path still propagates ZERO** (AP-142 clause (a)) — the
key fix must not perturb withdrawal/delete/EndGeneration edges; the §6
matrix re-runs them with a player-range parent.
3. **`RebucketLiveEntityPresentationOnly` does not become a canonical
writer again** — the D4 demotion stands; the presentation rebucket
stays keyed on the child guid alone.
4. **The `ParentEvent` path's incarnation gating (AP-132) is unchanged**
a wire-named stale incarnation still discards/queues exactly as today.
5. **No new canonical cell writer is introduced.** The fix adds zero
`SetFullCell` call sites; it only makes the existing D1/D2 sites find
their children.
6. **The dormant-residence deferrals are untouched** (route 7 invariant 9);
the continuation executor's parent replay path benefits from F1
automatically (it routes through the same commit pair).
---
## 6. Test plan
Rules (route 5 §7 / route 7 §6, verbatim where they apply): assert the
layer that broke; positive facts, not absences; every new test fails
against a broken implementation; sabotage-verify both halves of dual-layer
assertions; never derive a test's workload from the constant under test.
**The structural rule this issue adds — MANDATORY: every scenario runs as a
dual-parent-class matrix.** One parent in the player range (`0x5…`, spawned
with a nonzero incarnation — use a value > 1 so an off-by-one cannot pass),
one in the creature/dynamic range (`0x8…`, incarnation 0), asserting
IDENTICAL outcomes. #319 exists precisely because every prior test and both
captured gate logs used sequence-0 parents; a matrix in which the two
classes can diverge silently is the defect's habitat. Where a fixture
helper spawns parents, the helper takes the incarnation as a parameter —
no baked-in zero.
Focused tests (`tests/AcDream.App.Tests` for the controller/producer,
`tests/AcDream.Runtime.Tests` for the propagation surface):
1. **The key itself (F1), CreateObject path, matrix.** A CreateObject-carried
equip against a live parent with incarnation N (N=0 creature, N=7
player): relation commits under `(parentGuid, N)`
(`TryGetCommittedParent` returns N), D1 re-cells the child to the
parent's exact cell at attach (`cause=attach` behavior — assert the cell
equality, not the probe), and a subsequent parent canonical-cell change
through each writer family reaches the child (D2). Sabotage: restore the
literal `0` and confirm the PLAYER row fails while the creature row
passes — the test must be able to see exactly #319.
2. **The deferred-parent flavor (F1), matrix.** Child CreateObject arrives
BEFORE the parent's: relation stays unresolved (never staged under a
guessed key — assert no committed relation exists), parent arrives with
incarnation N, relation resolves and commits under N, child celled.
Companion: parent arrives cell-less, later gains a cell → D2 catch-up
re-cells the child (route 7 §6 test 1's companion, now for the
CreateObject producer).
3. **Hydration gate (§3.1).** An attached child with nonzero canonical
cell; its landblock reloads; assert the child is NOT a hydration
candidate (no legacy rebucket, `entity.ParentCellId` untouched by the
hydration pass, no `CommitRebucket` invocation for the child). Sabotage:
delete the gate → test fails.
4. **Shadow gate (§3.2).** Attached child, nonzero cell, driven through a
Hidden→Visible edge: assert NO `ShadowObjects` row exists for the child
after the transition (assert against the engine's shadow table, not a
cache — the AP-145/#318 lesson). Sabotage: delete the gate → fails.
5. **Residence non-event (§3.3).** Re-CreateObject shapes against an
attached nonzero-cell child (same-incarnation refresh; new-incarnation
replacement): outcomes byte-identical to the zero-cell baseline; the
replacement path's zero-then-re-cell stays inside one transaction (no
observable cell-less window — reuse route 7 test 1's observation
technique).
6. **Unwield classification population (§2.1 item 4).** An attached child
whose parent cell is nonzero (and deliberately DIFFERENT from the wire
Position's cell, the stale flavor) receives an unparent Position:
classification takes the TELEPORT_TS/distance route (never the
cell-less arm), the drop commits at the WIRE position, and — the §11
guard — the 4b-3 synthetic `PreMergeCommittedCellId == 0` fixtures are
NOT relabeled: they remain synthetic.
7. **Withdrawal/delete matrix (invariant 2).** Pickup of the parent,
delete of the parent, `EndGeneration` — child (and a grandchild) go
cell-less, both parent classes.
8. **The commit-time tripwire (F1).** A relation carrying a wrong
incarnation reaching the commit path is REFUSED loudly; assert the
refusal is observable (exception or logged refusal + no committed
relation), not a silent success.
9. **Ledger convergence** (route 7 invariant 13): teardown/reset with a
player-parented committed child present.
The existing route-7 suites run unmodified — zero expectation changes
outside the new tests is the tripwire that F1/F2 changed no non-child
behavior.
---
## 7. The gate (connected, user-run) — the CORRECTED criterion, instantiated
Release build, `ACDREAM_RETAIL_UI=1`, `ACDREAM_PROBE_CHILD_CELL=1`, live
ACE, graceful close. Run BOTH halves; a session that runs only the
creature half is a not-run for this issue (process rule (g): the gate must
be able to see the defect — the defect is player-class-only).
**Half A — player parent (the local player, `0x5000000A`):**
1. Login with equipment. **PASS requires a `[child-cell]` line for the
player's child at attach-or-activation** (`cause=attach` if the player
was celled first, else the D2 catch-up line when login activation
commits the player's first cell) **AND the child's `FullCellId` read
equal to the player's, nonzero.** Today this line does not exist
(`c4-gates.log:238`, `c5-gates.log:343` — attach with no probe line);
its appearance is the direct #319 signal.
2. Carry across ≥2 landblock boundaries and back, plus one
EnvCell-to-EnvCell dungeon traversal. **PASS: child `FullCellId` EQUALS
the player's at every checkpoint — a zero is a FAILURE, not a silence.**
Expectation set honestly by §2: during WASD the player's canonical cell
does not change, so `cause=propagate` lines are NOT expected at these
crossings for the player half — equality (both records carrying the
same value) is the assertion. This expectation is itself part of the
gate record: if `propagate` lines DO appear here, something changed the
player's cell writers and the session gets investigated, not celebrated.
3. Portal recall / portal transit while equipped. **PASS: `cause=propagate`
fires for the player's child at the arrival commit** (the teleport
writer is the player's live cell-change edge), child equal to the
player's destination cell, equipment present and following.
4. Unequip/re-equip mid-session (the ParentEvent path): `cause=attach`
with the true incarnation — this was the investigation §7 prediction;
observing it closes that loop.
5. Reconnect with equipment: re-attach produces the same line set as step 1.
**Half B — creature parent (`0x7…`/`0x8…`, e.g. observe an armed NPC or
`@teleto` a wielding creature):** the route-7 recipe unchanged —
`cause=propagate` in double digits across several crossings, child equal
to parent at checkpoints. This half proves the fix did not perturb the
already-working class. **ADDED (architecture review A4, 2026-08-05):**
this half must ALSO watch the creature-parented weapon's shadow/collision
row across a Hidden→Visible edge (`@hide`/`@unhide` an armed NPC, or a
sit/stand-equivalent state toggle if available) — F2's `RestoreShadow`
gate (`LiveEntityPresentationController.cs`) is NOT behavior-preserving
at HEAD for this class the way the contract's §3.2 originally claimed:
route 7's D1 already gives a creature-parented child a nonzero cell, so
this gate is a live behavior change here (the weapon's broadphase row no
longer refreshes on that edge), not an inert one. Confirm no #184-shaped
regression (a stale-but-solid collider) and no crash; the change is
believed correct per route 7's P4 record but was never exercised by a
connected session before this note. **ADDED (retail review round 2, D1,
2026-08-05):** this half must ALSO carry an armed creature/NPC across a
landblock UNLOAD and RELOAD (walk it out of streaming range and back, or
force a landblock reload if the harness supports it) and confirm the
weapon is still attached and correctly placed afterward — F2's hydration
gate (`LiveEntityHydrationController.OnLandblockLoaded`) is likewise NOT
behavior-preserving at HEAD for the creature class (§3.1's correction,
same D1-already-nonzero fact): pre-fix a creature-parented child was
already a hydration candidate on landblock reload and took the legacy
`RebucketLiveEntity` path this gate now excludes. This is precisely route
7's "left behind at a boundary" regression shape if the gate's direction
were wrong; confirm it is not.
Secondary check only: probe-line volume. Primary: the equality reads.
Regressions to watch: route 7's list verbatim (weapon at origin/stale
ground position, invisible while equipped, left behind at a boundary,
invisible-but-solid, culled against parent) — none is expected, since the
presentation path is untouched.
---
## 8. Size estimate and the split call
Calibrated against the campaign (route 7: ~127 production lines; route 3:
~418):
| piece | non-comment production lines |
|---|---|
| F1 key + deferred late-bind + commit tripwire | 2560 |
| F2 hydration gate | 412 |
| F2 shadow gate | 410 |
| F3 | 0 |
| **total** | **~3580** |
Tests are the larger share (~250450 lines, dominated by the matrix).
**ONE slice, no split.** F1 without F2 wakes the two sites; F2 without F1
is inert scaffolding; both are tiny. **If Decision 1 had gone to (a), the
answer would be different in kind, and it is worth stating for the record:
folding player-cell tracking in would put the slice at route-3 scale or
above** (the §2.2 enumeration: projection-controller change, rebucket
contract change, `Rebucketed` publication audit, route-2/4b-3
classification-input audit, portal-space race analysis, spatial-root/park
analysis, plus its own connected gate) **— and it would still be the wrong
bundling**, because the child fix is complete and gate-verifiable without
it, and the player-cell question deserves its own retail-conformance
argument rather than riding a key repair.
Stop and report rather than pushing through when:
1. F1's deferred path needs new relation-state machinery beyond a
wildcard/deferral flag (the unresolved-queue shape should suffice; if
`Resolve`'s staged early-return forces a redesign of
`ParentAttachmentState`'s state machine, that is a conversation, not an
ad-hoc build).
2. Any test in §6 requires touching a canonical cell writer.
3. The §3.2 gate turns out to mask a child shadow row that something DOES
legitimately create today (that would contradict route 7's P4 record —
file, do not fix inline).
4. The complete Release suite deviates from its measured baseline beyond
the two named flakes (#302, #308). Baseline at `e0f96a55` was 11,090 /
4 / 0 — MEASURE at the implementation HEAD, never inherit.
---
## 9. Open items — reported honestly, not smoothed
1. **The look-in render index nuance (§2.1 item 1) is argued from code,
not from a live A/B.** The claim "the local player already exhibits
this exact staleness in `_cellDynamics` with no observed symptom" is
solid at HEAD reading, but the `RenderSceneShadowRuntime:403`
residency assertion (`OwnerLandblockId != expected.LandblockId` throw)
was not chased to its trigger population. P-R1: the implementer runs
one indoor equipped session with the fix and confirms no shadow-runtime
assertion fires. If one does, that is a finding about the PLAYER's
record as much as the child's — report, do not patch the child.
2. **Whether the local player is a `RuntimePhysicsState` spatial root was
NOT established** (§2.2). If it is, the pre-existing stale player cell
can theoretically be swept by `ParkCollisionResidents` after a long
teleport-free WASD run — unexercised by the connected routes, which
teleport between stops. This rides in the follow-up issue as its first
verification step; it is NOT #319's blast radius (the fix does not
touch the player's record).
3. **The exact deferral mechanism for a parent-unknown CreateObject
relation is implementer's choice** within F1's pinned constraints; this
contract did not verify how `RetryWaitingDescendants` interacts with a
relation that was deliberately NOT staged (today it retries realize for
staged-but-unrealizable children). If the existing machinery cannot
carry it, stop condition 1 applies.
4. **Everything else in the investigation was confirmed at HEAD**: the
hardcoded 0 and its structural cause (the wire carries no parent
instance for CreateObject), both miss sites, the `Resolve` early-return,
the commit's missing validation, the three writers of the player's
canonical cell, the landblock-preserve, the hydration/shadow wake
mechanics (including `IsSpatiallyProjected = true` via the
presentation-only rebucket at `LiveEntityRuntime.cs:1043`), and the
render fallback. One investigation unknown was RESOLVED in the child's
favor: §5.1 item 3's residence question — the caller zeroes before
`Begin` (`RuntimeEntityObjectLifetime.cs:2751-2752`), §3.3.

View file

@ -0,0 +1,281 @@
# Issue #319 — retail-conformance DELTA review, round 2 (2026-08-05)
**Verdict: PASS, with one MAJOR documentation/gate-coverage finding (D1) that
must be closed before the connected gate runs — it does not require a code
change.**
Scope: delta only, against my round-1 report
([`2026-08-05-issue-319-retail-review.md`](2026-08-05-issue-319-retail-review.md)).
Same working tree, same base HEAD `af828a8a`, uncommitted. Reviewed
`git diff HEAD` (14 files, +934/-20) plus the three untracked docs.
Independent gates re-run for this round:
- `dotnet build AcDream.slnx -c Release` — exit 0.
- `AcDream.Runtime.Tests` Release — **1176/1176**, 0 skipped.
- `AcDream.App.Tests` Release — **4127 passed, 3 skipped, 0 failed**.
Matches the coordinator's reported numbers. A green suite is not evidence;
every finding below comes from source or pseudo-C.
---
## 1. The coordinator's primary question: does deleting our deferral diverge
## from retail's queue-by-GUID replay?
**No. Answered structurally, and it needs no register row.**
Retail's mechanism is real: `QueueBlobForObject` @0x005092D0 buckets a
missing-parent blob under the parent's GUID on `CObjectMaint` and replays it
when that GUID is (re)created (AP-132's retail half). The question is whether
acdream still has that mechanism after the App-layer deferral was deleted.
It does, and the deleted code was never it. acdream's port of retail's
per-GUID blob bucket lives in `ParentAttachmentState` and is **untouched by
this diff**:
- `_deferredCreatesByParent` (`ParentAttachmentState.cs:22-23`) — raw
CreateObjects waiting on a parent GUID, filled by `EnqueueDeferredCreate`
(`:87-119`) from `RuntimeEntityObjectLifetime.RegisterEntityCore:798-812`.
- `_deferredAcceptedRelationsByParent` (`:34-35`) — accepted relations waiting
on a parent GUID. Its own doc comment already names the anchor: "Shares the
SAME per-guid 'blobs waiting on guid X' shape … retail's
`QueueBlobForObject`/`CObjectMaint` bucket does not distinguish a raw Create
blob from any other blob type queued against the same guid."
- Replay on parent arrival is live: `DetachDeferredCreates` /
`DetachDeferredAcceptedRelations` are driven from
`RuntimeInitialCreateContinuationExecutor.ReplayDeferredChildren:1250-1275`,
whose comment cites retail's atomic per-parent detach (pseudo-C ~93617) and
whose `RestoreDeferredCreates` path explicitly preserves retail's "blobs live
on `CObjectMaint`, not on the object instance, so they survive the object and
replay against a recreated GUID."
So the deleted `DeferCreateObjectRelation` was a **third, redundant queue** at
the App producer layer, sitting *downstream* of the layer that already
implements retail's mechanism — and downstream of the very gate that makes it
unreachable. Removing it removes duplication, not a retail behaviour.
Verified again for this round that the gate covers both wire shapes:
`RegisterEntityCore:798` reads
`incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u`, and
`SameGenerationCreateObjectEvents` (the sole source of `CreateParentUpdate`)
are produced inside `AcceptCreate`, reached only after that gate passes. The
implementer's structural-unreachability proof is correct for both producers,
which is what round 1's R1 found independently.
**No register row is owed.** The divergence that exists here — acdream gating
queued relations on parent incarnation where retail's replay is pointer-only —
is AP-132, already filed, and this diff does not widen it. `_unresolvedByChild`
(the ParentEvent queue) is untouched.
**Retail-side consequence of the simplification:** with deferral gone, the fix
is exactly "bind to the parent's live incarnation at accept time," and the
parent is guaranteed addressable at that moment by the layer above. That is a
*closer* mapping of `GetObjectA(this, parent_id)` @0x00558a2d than the deferred
version was — retail resolves the GUID once, at the attach, against whatever
`CObjectMaint` currently holds; it does not carry a pending relation forward at
the attach site either. `GetNullObject` @0x005093e6 (the `SetChildren`
placeholder) is retail's only "not yet constructed" accommodation, and it lives
on the *parent-names-children* direction, which acdream does not implement at
this producer. Nothing retail does was lost.
## 2. The refusal paths leave retail-correct state — verified
Two refusal sites, both new:
**(a) `AcceptLateBoundCreateObjectRelation`'s else branch**
(`EquippedChildRenderController.cs:866-873`). Logs to stderr, mutates nothing.
No relation is staged, queued, or committed; the child keeps its own snapshot
and cell. That is a retail-representable state (an unattached object), and the
round-2 test `OnCreateParentAccepted_ParentNotYetKnown_RefusesWithoutStateOrCrash`
additionally pins that a later parent arrival does **not** retroactively attach
it — honest about the consequence rather than implying a recovery that does not
exist.
**(b) `CanCommitIncarnation`** (`ParentAttachmentState.cs:588-604`, called at
`EquippedChildRenderController.cs:983` and
`RuntimeLiveEntitySessionController.cs:414`). Verified the full refusal
sequence:
- It is genuinely pure — reads `resolveParentInstance`, writes nothing.
- Both production call sites now evaluate it **before** their canonical
mutation (`CommitStagedParent` / `TryCommitParent`), so the A1 torn
transaction is structurally impossible, not merely unlikely.
- On refusal both call `RejectProjection` (`:649-658`), which removes the
staged relation **only if it matches exactly** — so nothing is stranded to
block `Resolve` forever, and a concurrently-replaced staged relation is not
clobbered.
- The App site returns `CanAdvanceWireQueue: true`, and since the staged
relation is gone the enclosing `while` loop's next `TryGetStagedProjection`
fails and breaks. No infinite loop, and the recovery branch still runs.
- `CommitProjection` re-checks the same precondition as its own first mutation-
free step (`:625-626`), so the method is non-tearing for any caller, not only
the two that pre-check.
The switch from `throw` to logged `false` also moots round 1's **R6** in the
way that matters: I flagged that I could not exhaustively prove
`_inbound._snapshots[parent].InstanceSequence` never leads
`_activeByGuid[parent].Incarnation` inside a re-Create transaction. That gap
still exists as a fact, but its consequence changed from "destructive throw on
an unproven window" to "a logged refusal and an unattached child" — an outcome
retail can represent. Round 1's R6 is **withdrawn as a risk** and downgraded to
the observation in §5 below.
## 3. Round-1 findings — disposition
| round 1 | status |
|---|---|
| **R1** (both deferred branches unreachable) | **Resolved by deletion.** The unreachable code is gone; the remaining else branch logs. The `AcceptLateBoundCreateObjectRelation` `<remarks>` block (`:57-78`) states the structural argument correctly, including that it is "not an empirical absence, a structural one." |
| **R2** (sentinel-0 collides with `EndGeneration`/`DeleteGeneration` filters) | **Moot.** `LateBindParentInstance` and `DeferCreateObjectRelation` are gone; no relation with a placeholder incarnation ever enters `_unresolvedByChild`. Verified `FilterParentCandidates` (`:1002-1018`) now only ever sees wire-named incarnations. |
| **R3** (two wrong test rationales) | **Corrected, but incompletely — see D2.** Both now cite the zero-`FullCellId` inertness and acknowledge `HasCommittedParent` is child-keyed and committed pre-fix. |
| **R4** (D1's comment made vacuous) | **Fixed correctly.** `RuntimeEntityObjectLifetime.cs:1531-1539` now distinguishes the ParentEvent producer (protection ACTIVE, AP-132) from the CreateObject producer (vacuous-by-design, enforced by `CanCommitIncarnation`). Accurate. |
| **R5** (cross-file "above") | **Fixed.** `LiveEntityPresentationController.cs:211` now names `LiveEntityHydrationController.OnLandblockLoaded`. |
| **R6** (tripwire throw provability) | **Withdrawn as a risk** — see §2. |
| **R7** (hydration gate broader than @0x00515D40) | **Fixed.** `LiveEntityHydrationController.cs:179-187` now records that the exclusion covers the whole candidate loop and rests on route 7's P4 record, not on @0x00515D40 alone. |
## 4. Contract §1 row 5 correction — verified correct, cite it freely
`docs/research/2026-08-05-issue-319-contract.md:82`. Every address in the
correction was re-read at the pseudo-C for this round and is exact:
- `this->m_position.objcell_id = objcell_id` @0x00515385 (same-cell branch) ✓
- child loop `*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD
- `CPartArray::SetCellID` @0x005153CC
- loop bounds @0x005153AE@0x005153D8 (`do { … } while (ebx_1 < this->children->num_objects)`) ✓
- cross-cell branch `CPhysicsObj::change_cell(this, curr_cell)` @0x00515372
- function header `CPhysicsObj::SetPositionInternal(CPhysicsObj*, CTransition const*)` @0x00515330 ✓ (the 4-arg overload at @0x00515BD0 is a different function; the row cites the right one)
The characterisation — "retail's D2 equivalent lives INSIDE THE SAME FUNCTION
as the player's own per-tick cell write" — is accurate and is the strongest
single anchor for both the equality invariant and AP-146. Safe for future
sessions to cite.
---
## 5. New findings this round
### D1 — MAJOR (documentation + gate coverage; no code change required). The A4 creature-parent correction was applied to `RestoreShadow` and NOT to the hydration gate, which carries the identical live behaviour change — and no gate watch item covers it.
**Sites:** `src/AcDream.App/World/LiveEntityHydrationController.cs:569-570`
(the gate and its comment block `:551-569`);
`docs/research/2026-08-05-issue-319-contract.md:247-276` (§3.1, uncorrected);
`docs/research/2026-08-05-issue-319-contract.md:544-559` (§7 Half B watch item,
covers only `RestoreShadow`); `docs/ISSUES.md:13370-13374` (records A4 for §3.2
only).
The architecture review's A4 established that §3.2's "no-ops today on
`FullCellId == 0`" premise holds **only for player-parented children**, because
route 7's D1 already re-cells CREATURE-parented children to a nonzero cell — so
the `RestoreShadow` gate is a live behaviour change for that class. That
correction is applied at `LiveEntityPresentationController.cs:212-220` and
mirrored into the contract's Half B recipe.
**The identical argument applies to the hydration gate, and nothing records
it.** Verified chain at HEAD for a creature/static parent (incarnation 0):
1. `OnSpawn` stages the relation with the hardcoded `0`, which **matches** the
parent's real incarnation.
2. D1's gate `parent.Incarnation == parentInstanceSequence`
(`RuntimeEntityObjectLifetime.cs:1541-1543`) passes → `SetFullCell(child,
parent.FullCellId, …)`. The child's canonical cell is **nonzero at HEAD**.
3. `ProjectionCellId => WorldEntity is not null ? FullCellId : …`
(`LiveEntityRuntime.cs:387-389`) → nonzero for a realized child.
4. `OnLandblockLoaded`'s candidate filter (`:571-577`) admits on
`projectionCellId != 0` + landblock match + `SetupTableId is not null`. An
equipped weapon satisfies all three when the parent's landblock loads.
5. It therefore enters the second loop and takes one of
`ProjectExact(CreateSupersessionRecovery)` (`:599`),
`RebucketLiveEntity` (`:611`), or `ProjectExact(SpatialRecovery)` (`:618`).
`RebucketLiveEntity` writes `entity.ParentCellId` unconditionally
(`LiveEntityRuntime.cs:885-898`, no attached-child guard) and calls
`_spatial.RebucketLiveEntity` — a **second spatial writer** competing with
route 7 D4's `RebucketEquippedChildPresentation`.
So the hydration gate removes a live code path for creature-parented children,
exactly as the shadow gate does. The direction is right (route 7's P4/D4, and
retail's `update_object` `parent != 0` early-out @0x00515D40 for the canonical
half), but three things are wrong as it stands:
- The gate's own comment (`:551-569`) still frames the change as affecting only
"a #319-fixed (nonzero) child", i.e. the player class.
- The contract's §3.1 still asserts "A committed child today falls through all
three sources to `0`" and "The gate is correct TODAY (it changes nothing for
a zero-cell child)" — **false for the creature class**, and it is the
sentence a future session will read as the justification.
- **Half B of the connected gate watches the shadow row but not the hydration
path.** A creature-parented weapon that stops being re-placed on landblock
load is precisely route 7's "left behind at a boundary" regression shape, and
nothing in §7 asks the runner to look for it.
**Correct behaviour:** mirror A4 into the hydration gate's comment and contract
§3.1, and add a Half B step that carries an armed NPC across a landblock
boundary (the parent's landblock unloading and reloading) and confirms the
weapon still follows. Blast radius is one paragraph of docs plus one gate step,
not code.
One link in the chain I did **not** close empirically and am flagging rather
than guessing: whether a realized attached child reliably has
`InitialHydrationCompleted == true` (`TryMarkInitialHydrationCompleted`,
`LiveEntityRuntime.cs:2913-2928`, requires only `WorldEntity != null` and
`ResourcesRegistered`, with no `ProjectionKind` filter). This only decides
*which* of the three second-loop branches the child took at HEAD — candidacy,
and therefore the behaviour change, holds either way.
### D2 — MINOR. The R3 comment corrections are precise for the player row and imprecise for the creature row, in tests whose second row IS the creature class.
`tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs:150-158`
and `tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs:102-109`.
Both corrected comments end with "what was unreachable pre-fix is the NONZERO
`FullCellId` this candidate loop actually gates on … the child's cell stays 0
forever for a player parent." True for the player row
(`0x50000123u`/`0x50000456u`); **false for the creature row**
(`0x70000099u`/`0x70000199u`), where D1 already produced a nonzero cell at
HEAD — the same D1 fact that drives A4. Correct behaviour: qualify the sentence
per parent class, the way `LiveEntityPresentationController.cs:212-220` now
does for the production comment.
### D3 — LOW / observation. Two new unconditional `Console.Error.WriteLine` sites on paths reachable at wire cadence.
`EquippedChildRenderController.cs:868-873` and
`ParentAttachmentState.cs:601-606`. Both are "should be structurally
unreachable" refusals, so volume is expected to be zero — but neither is
rate-limited or routed through a diagnostic owner, and CLAUDE.md's rule 5
prefers a subsystem diagnostic owner over ad-hoc writes. If either ever fires
on a per-frame retry path it becomes a log flood on a host that must survive
long endurance sessions (Slice K4's own constraint). Not blocking; worth a
follow-up rather than a change in this slice.
### D4 — OBSERVATION (favourable). The ledger-convergence gap I judged non-blocking in round 1 was closed anyway.
Contract §6 test 9 now exists as three dual-parent-class tests
(`ParentAttachmentStateTests`: child removal, parent removal, full teardown
with mixed pending state), each asserting all four tables converge to zero.
Notably the teardown test deliberately leaves a live `_unresolvedByChild`
ParentEvent entry — proving convergence for the queue this fix did **not**
touch, which is the right target now that the deferred queue is gone. The
round-2 `PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit`
test is also well-aimed: it asserts at the **canonical** layer
(`snapshot.ParentGuid` null, `snapshot.Position` non-null) rather than at the
relation table, which is the layer the A1 tear actually corrupted.
Contract §6 tests 5 and 6 remain unwritten; my round-1 judgement stands
(test 5's premise is code-verified at
`RuntimeEntityObjectLifetime.cs:2750-2752`; test 6 pins a flavour the
short-circuit OR makes unobservable). Neither blocks.
---
## 6. Errors in the CONTRACT itself, round 2
One, and it is D1's second bullet: **§3.1 (`:247-276`) was not given the A4
correction its twin §3.2 received.** Its "the gate is correct TODAY (it changes
nothing for a zero-cell child)" is false for creature-parented children and is
the sentence most likely to be cited later. §3.2's body text (`:279-289`, "no-ops
today on `record.FullCellId == 0`") has the same residue, though the §7 Half B
note now overrides it — §3.1 has no such override anywhere.
Everything else re-checked this round — §1's table including the new row-5
correction, §4 F1/F2's pinned constraints, §5's invariants, §7's corrected
criterion and its new Half B addendum, AP-142 clause (f), AP-132's amendment,
AP-146, and #320 — remains accurate against source and pseudo-C.

View file

@ -0,0 +1,380 @@
# Issue #319 — independent retail-conformance review (2026-08-05)
**Verdict: PASS.**
Reviewed: the uncommitted working tree at base HEAD `af828a8a`, branch
`claude/acdream-physics-divergence-5aa784``git diff HEAD` (13 files,
+709/-30) plus the untracked contract
`docs/research/2026-08-05-issue-319-contract.md`.
Review only. No file in the tree was modified by this review except this
report.
Independent gates run for this review (a green suite is not evidence, but a
red one is):
- `dotnet build AcDream.slnx -c Release` — exit 0.
- `AcDream.Runtime.Tests` Release — 1170/1170, 0 skipped.
- `AcDream.App.Tests` Release — 4125 passed, 3 skipped, 0 failed.
---
## 1. The central retail claim — independently verified, and the contract
## UNDERSTATES its own anchor
Every retail claim below was read at
`docs/research/named-retail/acclient_2013_pseudo_c.txt` for this review. None
was inherited from the contract or the investigation.
| contract claim | address | verified |
|---|---|---|
| CreateObject's attach is GUID-only | `ACCObjectMaint::CreateObject` @0x00558870; `PhysicsDesc::get_parent_id` @0x00558a18`if (eax_20 != 0)` @0x00558a1f`CObjectMaint::GetObjectA(this, eax_20)` @0x00558a2d`get_parent_location_id` @0x00558a31`CPhysicsObj::set_parent(result, arg2, eax_22)` @0x00558a3e | ✓ **exact**. `GetObjectA` takes `(this, guid)`. There is no instance-sequence read, comparison, or argument anywhere in the block. |
| The reverse direction is the same shape | `CObjectMaint::SetChildren` @0x00509370: `unparent_children` @0x00509379, hash-bucket walk on `PhysicsDesc::get_child_id` @0x005093b2@0x005093ca, `GetNullObject(this, get_child_id(...), 1)` placeholder @0x005093e6, `set_parent(hash_next_1, arg2, get_child_location_id(...))` @0x005093f8 | ✓ **exact**, including the `GetNullObject` placeholder for a not-yet-constructed child. No instance field participates. |
| No player branch in the relation path | `set_parent` @0x00515A90 (3-arg) and @0x00515B50 (4-arg, Frame); `enter_cell` @0x00510ED0; `change_cell` @0x00513390 | ✓. Both `set_parent` overloads operate on `CPhysicsObj*` uniformly: `add_child``unset_parent``leave_world``parent = edi``if (cell != 0) change_cell``UpdateChild``recalc_cross_cells`. The only conditional in either body is the parent's `state & 0x4000` no-draw cascade @0x00515B26. `enter_cell`'s only guard is `part_array != 0` @0x00510ED8. **No player test exists in any of them.** |
| Retail's player cell is written per tick | `CPhysicsObj::SetPositionInternal` @0x00515330 | ✓ — and **stronger than the contract states**, see below. |
| `update_object` early-outs on a parented object | @0x00515D40: `if ((this_3->parent != 0 \|\| (this_3->cell == 0 \|\| (this_3->state & 0x1000000) != 0))) { transient_state &= ~0x80; return; }` | ✓ **exact**. A parented child is never independently updated or re-placed. |
**The contract's §1 row 5 cites the weaker half of its own best anchor.**
`SetPositionInternal` @0x00515330 does not merely write the mover's own cell:
in the same-cell branch it writes `this->m_position.objcell_id = objcell_id`
@0x00515385, then **walks `this->children` and writes each child's cell id
directly** — `*(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1` @0x005153BD,
followed by `CPartArray::SetCellID` @0x005153CC, looping @0x005153AE
@0x005153D8; the cross-cell branch delegates to `change_cell` @0x00515372.
That is retail's D2 equivalent living **inside the same per-tick function** as
the player's own cell write. It is a strengthening, not a contradiction: the
child-equals-parent equality invariant and AP-146's "retail's player cell is
never stale" both get a single anchor. (AP-142 clause (b) already cites this
range; only §1's summary table understates it.)
### Conclusion on the central claim
**Retail does NOT distinguish a player parent, and there is no
instance-sequence check anywhere in the relation path — confirmed
independently.** acdream's player/non-player split is exactly what the
contract says it is: an artifact of keying committed relations by
`(guid, incarnation)` against a wire message that carries only a GUID.
**Late-binding to whoever currently holds the GUID is the faithful mapping**,
because "the current holder of the GUID" is literally what `GetObjectA` /
`GetNullObject` return. The fix shape is right.
---
## 2. Scope expansion (`OnCreateParentAccepted`) — JUSTIFIED, and the fix is
## correct there
The contract's Scope line named only `EquippedChildRenderController.OnSpawn`.
The implementer also fixed `OnCreateParentAccepted`. Verified:
- `CreateParentUpdate` is declared at
`src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs:1399-1405`
as `(ChildGuid, ParentGuid, ParentLocation, PlacementId,
ChildInstanceSequence, ChildPositionSequence)`. Its producer,
`BuildSameGenerationEvents` (`:1240-1248`), fills
`ChildInstanceSequence` from `ts.Instance` where `ts` is
`incoming.Physics.Timestamps` and `incoming` **is the child**. So
`ChildInstanceSequence` is the CHILD's own timestamp, exactly as the
implementer reports. **No parent instance sequence exists on this shape.**
- The codebase already knew this: `InboundPhysicsStateController.
TryApplyCreateParent`'s pre-existing doc comment (`:216-218`) reads
"Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only
the child's shared POSITION_TS participates in freshness."
- Pre-fix, `OnCreateParentAccepted` hardcoded `ParentInstanceSequence: 0`
into the identical `Relations.AcceptCreateObjectRelation` call. **Byte-
identical defect.** Shipping the key fix on one producer and not the other
would have left #319 half-open for every same-generation ObjDesc refresh of
a player-parented child.
Routing both through one `AcceptLateBoundCreateObjectRelation`
(`EquippedChildRenderController.cs:836-857`) is the right shape and the right
commit.
---
## 3. Findings
### R1 — MINOR (incorrect load-bearing comment). Both deferred branches are production-unreachable, not just `OnSpawn`'s.
`tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs:875-881`
The test's XML doc asserts:
> `CreateParentUpdate`'s producer … has no equivalent parent-addressability
> precondition, so this is the reachable shape for #319's deferred branch.
**Verified false.** The only production driver of `OnCreateParentAccepted` is
`LiveEntityHydrationController.OnCreate`'s `result.SameGenerationEvents is
{ } refresh` branch (`LiveEntityHydrationController.cs:340-341` →
`LiveEntityNetworkUpdateController.cs:380`
`LiveEntitySameGenerationUpdateRouter.Apply``OnParent``:400-401`).
`SameGenerationCreateObjectEvents` are produced only inside
`InboundPhysicsStateController.AcceptCreate` (`:70-90`), reached only from
`RuntimeEntityObjectLifetime.RegisterEntityCore` — and `RegisterEntityCore`'s
deferral gate runs **before** `PreviewCreateDisposition`:
```
RuntimeEntityObjectLifetime.cs:798-812
uint parentGuid = incoming.ParentGuid ?? incoming.Physics?.Parent?.Guid ?? 0u;
if (beginInitialResidence && parentGuid != 0u
&& !Entities.TryGetActive(parentGuid, out _))
{ Entities.ParentAttachments.EnqueueDeferredCreate(...); return DeferredForParent: true; }
```
The `??` chain covers both the flattened `ParentGuid` and the nested
`Physics.Parent.Guid`, i.e. both shapes `OnSpawn` and `OnCreateParentAccepted`
read. The App graphical host is the `beginInitialResidence: true` caller
(`LiveEntityRuntime.cs:583`). So by the time either producer runs, the parent
is active. The implementer's `OnSpawn` finding is correct; the extension of it
to `OnCreateParentAccepted` is not.
Retail address contradicted: none — this is a reachability claim about
acdream, not about retail.
**Not a code defect.** The deferred machinery is correct, and it is genuinely
defensive: the deferral gate reads `Entities.TryGetActive` (the
`_activeByGuid` map) while the late-bind reads `TryGetSnapshot` (the
`_inbound._snapshots` map) — two distinct stores, so a producer-side fallback
is warranted rather than an assertion. Correct behaviour: the comment should
say the deferred branch is defensive against store drift and is not exercised
by any production path today, and the test should be labelled as covering the
mechanism rather than a reachable production shape.
### R2 — MINOR (latent bug, unreachable today by R1). The late-bind sentinel `0` is filtered as if it were a wire-named incarnation.
`src/AcDream.Runtime/Entities/ParentAttachmentState.cs:828-834` (`EndGeneration`)
and `:853-857` (`DeleteGeneration`), via `FilterParentCandidates` (`:1002-1018`).
`DeferCreateObjectRelation` (`:412-426`) queues the relation into
`_unresolvedByChild` carrying `ParentInstanceSequence: 0` plus
`LateBindParentInstance = true`. The `0` is a **meaningless placeholder**
that is the whole premise of the fix. But `EndGeneration`'s retain predicate
treats it as a wire-named value:
```
relation => relation.ParentInstanceSequence == replacementGeneration
|| PhysicsTimestampGate.IsNewer(replacementGeneration,
relation.ParentInstanceSequence)
```
`IsNewer(oldStamp, newStamp)` (`AcDream.Core/Physics/PhysicsTimestampGate.cs:58-63`)
returns true when `newStamp` is newer. For a player parent replaced at
generation 7: `0 == 7` false, `IsNewer(7, 0)` false → **the late-bind relation
is dropped**, which is the exact opposite of its own semantic ("attach to
whoever currently holds the GUID" — the replacement generation is precisely
whom it should attach to) and of retail's GUID-keyed blob replay
(`QueueBlobForObject`, AP-132's retail half: replay on GUID (re)creation with
only an addressability check).
Not reachable in production today because of R1, and **not a regression**
pre-fix a CreateObject relation went straight to `_stagedByChild`, which
`EndGeneration` clears unconditionally via `RemoveParentReferences`
(`:822-827`). Correct behaviour when the path becomes reachable: both
`FilterParentCandidates` retain predicates should keep
`relation.LateBindParentInstance` unconditionally, or the deferred relation
should carry a nullable incarnation rather than a `0` sentinel that collides
with a legitimate creature-parent value.
### R3 — MINOR (incorrect comment, two sites; the same wrong rationale twice).
`tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs:149-153`
and `tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs:101-105`.
Both comments justify the fixture setup with:
> …giving it a nonzero canonical `FullCellId` via D1/D2 (exercised
> elsewhere) — impossible to reach before #319's fix for a player-class
> parent.
Two errors:
1. **"the relation never committed for a player-class parent at all" is
false.** `HasCommittedParent` is keyed by the CHILD guid
(`ParentAttachmentState.cs:571-572`: `_lastAcceptedByChild.ContainsKey(childGuid)`),
and pre-fix `CommitProjection` succeeded under `(playerGuid, 0)` — the
contract's own §0 says so verbatim ("`TryCommitParent` never validates the
sequence; the attach succeeds silently"). A player-parented child **did**
have a committed parent at HEAD. What was inert at HEAD is the child's
zero `FullCellId`, which is the contract's actual §3.1/§3.2 argument.
2. **The nonzero cell in both fixtures does not come from D1/D2.** The
presentation fixture materialises at `0x01010001u`
(`LiveEntityPresentationControllerTests.cs:690`, seeded from the spawn's
own `ServerPosition` at `:744`); the hydration fixture's record carries
`Cell` from its own spawn. Neither test calls
`CommitAcceptedParentCellless`.
The tests themselves are **valid and their sabotage claims hold** — I
verified independently that `ProjectionCellId => WorldEntity is not null ?
FullCellId : …` (`LiveEntityRuntime.cs:387-389`) makes a committed child's
candidate cell exactly `FullCellId`, so it is 0 at HEAD and the parent's cell
post-fix; the gates are therefore behavior-preserving at HEAD, exactly as the
contract argued. Only the stated rationale is wrong. Correct behaviour: cite
the zero-`FullCellId` inertness, not a non-existent absence of a committed
relation.
### R4 — MINOR (comment made partially false by this diff; the comment itself is untouched).
`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:1529-1531` (D1):
> The committed relation (not the guid alone) resolves the parent so a stale
> or superseded incarnation can never re-cell the child.
After F1 a CreateObject-carried relation is, by construction, always equal to
the parent's live incarnation at commit (and the new tripwire at
`ParentAttachmentState.cs:651-661` enforces it). For that producer the
sentence now describes a protection that is vacuous rather than active. It
remains true for the `ParentEvent` producer, whose incarnation the wire names
(AP-132). The diff changed the semantics of a comment it did not edit — the
exact class the review brief flags. Correct behaviour: qualify the sentence to
name the ParentEvent producer.
### R5 — LOW (comment misdirection).
`src/AcDream.App/World/LiveEntityPresentationController.cs:228` — "the same
predicate the hydration gate above uses". The hydration gate is in a different
file (`LiveEntityHydrationController.OnLandblockLoaded:561`), not "above".
### R6 — OBSERVATION, partly UNVERIFIABLE (flagged rather than guessed). The tripwire converts a silent skip into a production throw.
`ParentAttachmentState.CommitProjection` (`:639-661`) now throws
`InvalidOperationException` when the parent is addressable and the relation's
incarnation disagrees. The contract explicitly authorised this ("throw or
logged refusal … pinned outcome: never a silent success"), so it is in scope.
I enumerated the false-positive surface as far as source allows:
- Only the **Staged** branch of `PrepareAndTryRealize`
(`EquippedChildRenderController.cs:954-960`) reaches it in the graphical
host; the Recovery branch does not.
- That branch is gated by `ValidateParentProjection` (`:986-996`), which
requires a live `LiveEntityRecord` for the parent.
- A staged relation is staged either at the parent's live incarnation
(`AcceptLateBoundCreateObjectRelation:846-853`), or after `Resolve`'s
equality check (`ParentAttachmentState.cs:484-498`), or by the late-bind
adoption (`:473-483`).
- A parent generation change removes every staged/recovery/committed
reference to that parent (`EndGeneration:822-827`,
`DeleteGeneration:847-852`).
- In the headless host (`RuntimeLiveEntitySessionController.cs:410`) relations
originate only from `Enqueue(ParentEvent.Parsed)` and pass `Resolve`'s
equality gate, so the tripwire is a no-op there.
**What I could NOT establish:** whether `_inbound._snapshots[parent].
InstanceSequence` (the tripwire's source, reached via `TryGetSnapshot`) can
ever lead `_activeByGuid[parent].Incarnation` inside a re-CreateObject
transaction — `AcceptCreate` writes `_snapshots[guid] = incoming`
(`InboundPhysicsStateController.cs:77`) before `RegisterEntityCore` completes
the record replacement, and the two stores are documented as "related but
distinct" (`:1198-1210`). I found no call into `CommitProjection` inside that
window, but I did not prove the absence exhaustively. Reporting as
unverified. If the connected gate ever produces this exception, the message
already names #319 and the two incarnations, which is the right diagnostic.
### R7 — OBSERVATION. The hydration gate is broader than its cited retail anchor.
`LiveEntityHydrationController.cs:551-562` excludes a committed child from the
**entire** candidate loop, so `ProjectExact(CreateSupersessionRecovery)`
(`:595`) and `ProjectExact(SpatialRecovery)` (`:614`) are also refused for it,
not only the legacy `RebucketLiveEntity → CommitRebucket` branch (`:607`).
This is what the contract pinned ("the candidate loop excludes records with a
committed parent", §3.1) and it is behavior-preserving at HEAD (verified: a
committed child's `projectionCellId` is `FullCellId` = 0 today). But the
retail anchor in the comment, `update_object`'s `parent != 0` early-out
@0x00515D40, speaks to **physics re-placement**, not to render-projection
recovery. The broader exclusion actually rests on route 7's P4 record (a
committed child is never a spatial root, joins no workset, has no shadow row)
plus the fact that a child's projection is driven exclusively through
`EquippedChildRenderController`'s realize/retry path. Worth one sentence in
AP-142 clause (f) or the contract rather than a code change — as written the
comment claims more anchoring than @0x00515D40 provides.
---
## 4. The three unwritten contract tests — NONE blocks
- **§6 test 5 (residence non-event).** Not blocking. I verified §3.3's premise
directly in source rather than relying on the test:
`RuntimeEntityObjectLifetime.InitializeAcceptedCreateResidence:2750-2752`
reads `if (canonical.FullCellId != 0u) Entities.SetFullCell(canonical, 0u,
0u);` immediately before `InitialCreateResidences.Begin`. `Begin`'s
`FullCellId != 0` refusal is therefore a defensive invariant, not a
reachable gate, exactly as §3.3 concluded. The zero-then-re-cell atomicity
claim remains argued-not-measured, but no production change was made at
that site.
- **§6 test 6 (unwield classification population).** Not blocking. The
contract itself recorded (from `af828a8a`) that ACE advances TELEPORT_TS at
a drop and the classifier is a short-circuit OR
(`RuntimeAuthoritativePositionRouteClassifier.cs:391`), so the observable
label is `teleport-ts` either way. The test would pin a flavour, not a
behaviour the fix changes.
- **§6 test 9 (ledger convergence).** Not blocking, and I checked why:
`RestoreShadow`'s refusal for a committed child is byte-identical pre- and
post-fix (the `FullCellId == 0` clause already refused every committed child
at HEAD). The one ledger asymmetry that exists —
`_suspendedShadowOwners.Add` on `BecameHidden`
(`LiveEntityPresentationController.cs:172`) with no matching removal on
`BecameVisible` because `RestoreShadow` returns false — is **pre-existing
and unchanged**, and converges through `Forget`/`Clear` (`:117`, `:125`).
The connected §7 gate (both halves, player class mandatory) remains the real
acceptance test and has not run. That is correctly recorded in ISSUES.md's
#319 status line, which honestly says "FIX IMPLEMENTED, awaiting the connected
acceptance gate … NOT YET COMMITTED".
---
## 5. Register and bookkeeping — verified row by row
- **AP-146 (new row, `retail-divergence-register.md:174`).** Retail anchor
`CPhysicsObj::SetPositionInternal` @0x00515330 **verified** — the address
resolves to the `(CPhysicsObj*, CTransition const*)` overload (the 4-arg
`SetPositionInternal` at @0x00515BD0 is a different function; the row cites
the right one), and the cell write is unconditional across both branches
(`m_position.objcell_id = …` @0x00515385 same-cell, `change_cell`
@0x00515372 cross-cell). The three acdream writers, the
`LocalPlayerProjectionController` landblock coarsening, and the
`LiveEntityRuntime.cs:935-938` preserve are all cited. The risk column names
the unresolved `ParkCollisionResidents` question **openly** rather than
smoothing it. Register rule 1 is satisfied: this was a standing
undocumented divergence and the diff files it. Header count 102 → 103,
correct.
- **AP-142 clause (f).** Every retail address in the clause
(@0x00558a18/@0x00558a2d/@0x00558a3e, @0x00509370/@0x005093e6) was
re-verified above and is exact. The clause states the acdream side honestly
(the `(guid, incarnation)` key is acdream's, retail has none) and names the
tripwire. Correct.
- **AP-132 amendment.** The added sentence distinguishes the two producers
correctly and does not weaken the row's own gate. Verified against the wire
types: `ParentEvent.Parsed` carries `ParentInstanceSequence`;
`CreateParentUpdate` carries `ChildInstanceSequence`. Correct.
- **Route-7 contract §7 supersession blockquote.** Accurate — the old
criterion is genuinely unfalsifiable under #319 (a zero-cell child emits no
probe line, which the old criterion read as clean) and it points at the
corrected positive-equality criterion. Correct.
- **#320.** A faithful transcription of contract §2.2's five-item enumeration
plus §9 item 2 as the mandatory first verification step, with the correct
"do not implement without a fresh retail-conformance argument" gate. It
inherits exactly what #319 deliberately excluded — nothing more, nothing
less.
---
## 6. Errors in the CONTRACT itself
Two, neither material to the verdict:
1. **§1 row 5 understates its own anchor** (see §1 of this review).
`SetPositionInternal` @0x00515330 also walks `this->children` writing each
child's `objcell_id` @0x005153AE@0x005153D8 — retail's per-tick D2
equivalent in the same function. This *strengthens* the equality invariant
and AP-146; citing only the mover's own write leaves the strongest
available evidence on the table.
2. **§4 F4's "ISSUES.md: #319 → Recently closed with the commit SHA" was not
executed** — correctly, since the connected gate has not run and the tree
is uncommitted. The contract's instruction was written assuming the fix
commit and the gate land together. Deviation is honest and recorded in the
issue's own status line.
Everything else in the contract that this review touched — §0's defect chain,
§1's five rows, §2.1's four-consumer enumeration, §3.1/§3.2's wake mechanics,
§3.3's residence non-event, §4 F1's pinned constraints, §5's invariants — was
confirmed against source or pseudo-C.

View file

@ -70,6 +70,13 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly Func<RuntimeEntityKey, bool> _tickAttached;
private readonly Func<RuntimeEntityKey, bool> _reconcileAttached;
private int _activePoseCompositionVisits;
/// <summary>
/// #319 D3/B6 (retail + architecture review round 2, 2026-08-05):
/// per-child log-once latch for <see cref="AcceptLateBoundCreateObjectRelation"/>'s
/// unaddressable-parent refusal - see the identical rationale on
/// <c>ParentAttachmentState._loggedIncarnationRefusals</c>.
/// </summary>
private readonly HashSet<uint> _loggedUnaddressableParentRefusals = [];
internal int LastFullPoseCompositionVisits { get; private set; }
internal int LastReconcilePoseCompositionVisits { get; private set; }
@ -126,13 +133,12 @@ public sealed class EquippedChildRenderController : IDisposable
// 0x0051DDD0) default an absent AnimationFrame to zero.
// Parent remains a complete relation in that case.
uint placementId = spawn.PlacementId ?? 0u;
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
AcceptLateBoundCreateObjectRelation(
parentGuid,
spawn.Guid,
parentLocation,
placementId,
ParentInstanceSequence: 0,
spawn.PositionSequence));
spawn.PositionSequence);
}
ResolveAndTryRealize(spawn.Guid);
@ -796,17 +802,100 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
// CreateParentUpdate is the same-generation ObjDesc-refresh
// shape of a CreateObject-carried relation (BuildSameGenerationEvents,
// InboundPhysicsStateController.cs) - it carries the parent's
// GUID and location only, never an instance sequence, for the
// identical structural reason OnSpawn's raw CreateObject path
// does not (#319 F1).
AcceptLateBoundCreateObjectRelation(
update.ParentGuid,
update.ChildGuid,
update.ParentLocation,
update.PlacementId,
ParentInstanceSequence: 0,
update.ChildPositionSequence));
update.ChildPositionSequence);
ResolveAndTryRealize(update.ChildGuid);
}
}
/// <summary>
/// #319 F1: late-binds a CreateObject-carried parent relation (raw
/// CreateObject's <c>Physics.Parent</c> via <see cref="OnSpawn"/>, or
/// the same-generation <see cref="CreateParentUpdate"/> envelope via
/// <see cref="OnCreateParentAccepted"/>) to the parent's LIVE
/// incarnation, rather than assuming sequence zero. Neither wire shape
/// carries a parent instance sequence - retail's own attach is
/// GUID-only (<c>PhysicsDesc::get_parent_id</c> @0x00558a18 -&gt;
/// <c>GetObjectA</c> @0x00558a2d -&gt; <c>set_parent</c> @0x00558a3e),
/// so "the current holder of the guid" is the faithful mapping. The
/// parent's snapshot lookup here mirrors <see cref="ResolveRelations"/>'s
/// own lookup (<see cref="ResolveLiveParentInstance"/>).
/// </summary>
/// <remarks>
/// #319 A6 (architecture review, 2026-08-05): a queued/late-bind
/// deferral for an unaddressable parent was tried here and REMOVED.
/// <c>RuntimeEntityObjectLifetime.RegisterEntityCore</c>'s
/// <c>EnqueueDeferredCreate</c> gate (`:797-812`) defers the ENTIRE
/// CreateObject - both this raw shape (`incoming.ParentGuid`) and the
/// same-generation <see cref="CreateParentUpdate"/> envelope
/// (`incoming.Physics.Parent.Guid`, same `??` chain) - whenever its
/// parent is not yet addressable, and does so BEFORE either wire shape
/// is ever produced (`AcceptCreateCore`/`BuildSameGenerationEvents` are
/// reached only after that gate passes). So this method never observes
/// an unaddressable parent in production for EITHER producer - not an
/// empirical absence, a structural one. A deferral queue here carried
/// three independent defects (a missing child POSITION_TS gate, a
/// placeholder-incarnation collision with the generation filters,
/// unbounded mid-session accumulation) while being exercised by nothing
/// but a test that called this class's methods directly, bypassing the
/// production routing that makes the branch unreachable. If the else
/// branch below is ever reached, that upstream invariant has broken;
/// log loudly rather than reconstructing unreachable machinery to
/// paper over it. (B2, architecture review round 2: the upstream gate
/// tests <c>Entities.TryGetActive</c> - the active-record table - while
/// this method's own lookup below tests <c>_liveEntities.TryGetSnapshot</c>
/// - the inbound snapshot table. Active implies a snapshot exists,
/// because <c>AddActive</c> is fed from the same snapshot
/// <c>AcceptCreate</c> just wrote; the only known inversion window is
/// inside <c>TryDeleteEntity</c>, where the snapshot is removed several
/// statements before the active record, and reaching this method
/// through that window would require a re-entrant child CreateObject
/// inside it - not reachable from a single-threaded pump.) (B3: this
/// unreachability argument is scoped to the GRAPHICAL host - the
/// content-less direct/headless host has no
/// <c>EquippedChildRenderController</c> at all, so it has no
/// CreateObject-carried relation producer to which this argument would
/// even apply.)
/// </remarks>
private void AcceptLateBoundCreateObjectRelation(
uint parentGuid,
uint childGuid,
uint parentLocation,
uint placementId,
ushort childPositionSequence)
{
if (_liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn parentSpawn))
{
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid,
childGuid,
parentLocation,
placementId,
parentSpawn.InstanceSequence,
childPositionSequence));
}
else if (_loggedUnaddressableParentRefusals.Add(childGuid))
{
Console.Error.WriteLine(
$"equipment: parent 0x{parentGuid:X8} unaddressable for child " +
$"0x{childGuid:X8} at CreateObject-carried relation accept - " +
"refusing (#319 A6; should be structurally unreachable - see " +
"RuntimeEntityObjectLifetime.RegisterEntityCore's " +
"EnqueueDeferredCreate gate). Logged once for this child; " +
"further refusals for the same child are suppressed.");
}
}
private bool TryResolveExactAttachment(
AttachedChild child,
out WorldEntity parent)
@ -831,12 +920,20 @@ public sealed class EquippedChildRenderController : IDisposable
Relations.Resolve(
childGuid,
guid => _liveEntities.TryGetSnapshot(guid, out _),
guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null,
ResolveLiveParentInstance,
_acceptParent);
}
/// <summary>
/// The parent's LIVE incarnation, or null if not currently addressable.
/// Shared by <see cref="ResolveRelations"/>'s late-bind lookup and
/// <see cref="PrepareAndTryRealize"/>'s #319 F1 commit-time tripwire.
/// </summary>
private ushort? ResolveLiveParentInstance(uint parentGuid) =>
_liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null;
private bool ResolveAndTryRealize(uint childGuid)
{
bool projected = false;
@ -896,8 +993,25 @@ public sealed class EquippedChildRenderController : IDisposable
childCanonical.PositionAuthorityVersion;
if (candidateKind is ParentProjectionCandidateKind.Staged)
{
// #319 A1 (architecture review, 2026-08-05): the incarnation
// tripwire MUST be evaluated before either half of the commit
// runs. The original shape called CommitStagedParent (the
// canonical half) first and let CommitProjection's internal
// check throw second - by then the canonical layer had already
// been rewritten as parented, so a mismatch tore the
// transaction (canonically parented, no committed relation, a
// staged relation blocking Resolve forever) instead of
// refusing cleanly. This read-only pre-check runs before any
// mutation on either side.
if (!Relations.CanCommitIncarnation(relation, ResolveLiveParentInstance))
{
Relations.RejectProjection(relation);
return new ProjectionPreparationResult(
CanAdvanceWireQueue: true,
Projected: false);
}
if (!_liveEntities.CommitStagedParent(relation, out _)
|| !Relations.CommitProjection(relation))
|| !Relations.CommitProjection(relation, ResolveLiveParentInstance))
{
return default;
}
@ -1660,6 +1774,7 @@ public sealed class EquippedChildRenderController : IDisposable
_pendingReparentRemovalByChild.Clear();
_pendingPoseLossRemovalByChild.Clear();
_pendingOrphanRemovalByChild.Clear();
_loggedUnaddressableParentRefusals.Clear();
Relations.Clear();
}

View file

@ -548,6 +548,45 @@ AppearanceSynchronization:
continue;
}
// #319 F2 (contract §3.1): a committed child is never
// independently re-placed by landblock load - retail's
// update_object `parent != 0` early-out (@0x00515D40) means
// only the parent's own D1/D2 propagation may move it. Without
// this gate, a #319-fixed (nonzero) child's cell would make it
// a hydration candidate here and take the full legacy
// RebucketLiveEntity -> CommitRebucket branch below - a SECOND
// canonical cell writer for a child, the exact defect route 7
// exists to remove. Same predicate the collision-retirement
// sweep already uses (RuntimeSetPositionState.IsAffectedCollisionResident).
// Retail review R7 (2026-08-05): the exclusion below covers
// the ENTIRE candidate loop (ProjectExact's CreateSupersessionRecovery
// and SpatialRecovery branches too, not only the legacy
// RebucketLiveEntity branch @0x00515D40 speaks to). The
// broader scope rests on route 7's P4 record (a committed
// child is never a spatial root, joins no workset, has no
// shadow row) plus the fact a child's projection is driven
// exclusively through EquippedChildRenderController's
// realize/retry path - not on @0x00515D40 alone.
// CORRECTION (retail review round 2, D1, 2026-08-05): this gate
// is NOT a no-op at HEAD for every parent class. D1's attach
// re-cell (RuntimeEntityObjectLifetime.cs's
// `parent.Incarnation == parentInstanceSequence` gate) already
// matches for a CREATURE/static-range parent pre-fix (the
// hardcoded 0 IS that parent's real incarnation), so a
// creature-parented child already carries a nonzero canonical
// cell at HEAD and was ALREADY a hydration candidate here,
// taking RebucketLiveEntity's unconditional
// `entity.ParentCellId` write and spatial rebucket - a second
// writer competing with route 7 D4's presentation-only
// rebucket. This gate removes that live path for the creature
// class too, not only the player class the #319 fix newly
// affects. Direction is correct (route 7's P4/D4); see the
// #319 contract's §7 Half B for the connected-gate watch step
// this requires (carry an armed NPC across a landblock
// reload).
if (_runtime.ParentAttachments.HasCommittedParent(candidate.ServerGuid))
continue;
uint projectionCellId = projection?.ProjectionCellId
?? candidate.Snapshot.Position?.LandblockId
?? candidate.FullCellId;

View file

@ -217,7 +217,25 @@ public sealed class LiveEntityPresentationController : IDisposable
{
if (!record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0)
|| record.FullCellId == 0
// #319 F2 (contract §3.2): a committed child never owns an
// independent broadphase row (route 7's P4 record,
// RuntimeEntityDirectory.cs:451-465). IsSpatiallyProjected and
// IsSpatiallyVisible do NOT exclude a child - its presentation-
// only rebucket sets IsSpatiallyProjected=true every frame
// (LiveEntityRuntime.RebucketLiveEntityPresentationOnly) - so
// parentage must be checked directly, the same predicate
// LiveEntityHydrationController.OnLandblockLoaded's gate uses.
// CORRECTION (architecture review A4, 2026-08-05): the
// contract's §3.2 premise ("no-ops today on FullCellId == 0")
// holds only for PLAYER-parented children - route 7's D1
// already re-cells CREATURE-parented children to a nonzero
// cell, so this clause IS a live behavior change for that
// class (an NPC's wielded weapon no longer refreshes its
// shadow row on a Hidden->Visible edge). Right direction per
// route 7's P4 record; the connected gate's Half B must watch
// for it explicitly (issue #319 contract §7).
|| _liveEntities.ParentAttachments.HasCommittedParent(record.ServerGuid))
{
return false;
}

View file

@ -34,6 +34,19 @@ public sealed class ParentAttachmentState
private readonly Dictionary<uint, Queue<DeferredAcceptedParentRelation>>
_deferredAcceptedRelationsByParent = [];
private ulong _nextDeferredCreateAdmissionId;
/// <summary>
/// #319 D3/B6 (retail + architecture review round 2, 2026-08-05):
/// per-child log-once latch for <see cref="CanCommitIncarnation"/>'s
/// refusal. The refusal should be structurally unreachable in
/// production; if the invariant it depends on ever breaks for a
/// repeating producer, an unconditional log would spam once per
/// packet on a host that must survive long endurance sessions (Slice
/// K4). One line per distinct child is enough to make the break
/// discoverable without a flood. Does not affect
/// <see cref="CanCommitIncarnation"/>'s correctness-table purity - it
/// touches only this bookkeeping set, never the relation tables.
/// </summary>
private readonly HashSet<uint> _loggedIncarnationRefusals = [];
/// <summary>
/// Round 5 R5-2: cancellation-aware detach/restore window state, shared
@ -568,7 +581,59 @@ public sealed class ParentAttachmentState
}
}
public bool CommitProjection(ParentAttachmentRelation relation)
/// <summary>
/// #319 A1 (architecture review, 2026-08-05): pure, side-effect-free
/// precondition for the commit-time incarnation tripwire. Callable
/// BEFORE any canonical or relation-table mutation so a mismatch can be
/// refused without ever tearing a transaction — the original shape threw
/// from inside <see cref="CommitProjection"/>, which callers reached
/// AFTER already committing the canonical half
/// (<c>RuntimeEntityObjectLifetime.TryCommitParent</c>), leaving a child
/// the canonical layer believed was parented with no committed relation
/// and a staged relation blocking <see cref="Resolve"/> forever — the
/// exact torn-transaction outcome F1 pinned against. Logs and returns
/// <see langword="false"/> on a mismatch; never throws (Route 3's N3
/// principle: a possibly-transient condition must not become fatal on a
/// host that must survive long endurance sessions). Returns
/// <see langword="true"/> (no refusal) when
/// <paramref name="resolveParentInstance"/> is null or the parent is not
/// currently addressable — the tripwire only fires when the parent IS
/// live and DISAGREES with the relation's named incarnation.
/// </summary>
public bool CanCommitIncarnation(
ParentAttachmentRelation relation,
Func<uint, ushort?>? resolveParentInstance)
{
if (resolveParentInstance is null
|| resolveParentInstance(relation.ParentGuid) is not { } liveParentInstance
|| liveParentInstance == relation.ParentInstanceSequence)
{
return true;
}
if (_loggedIncarnationRefusals.Add(relation.ChildGuid))
{
Console.Error.WriteLine(
$"[parent-attach] refused: child=0x{relation.ChildGuid:X8} names " +
$"parent 0x{relation.ParentGuid:X8} incarnation " +
$"{relation.ParentInstanceSequence}, but the parent's live " +
$"incarnation is {liveParentInstance} (#319 tripwire). " +
"Logged once for this child; further refusals for the same " +
"child are suppressed.");
}
return false;
}
/// <summary>
/// Stages -&gt; committed transition for an accepted parent relation.
/// Calls <see cref="CanCommitIncarnation"/> as its own first check
/// (before any mutation here) so this method is non-tearing on its own
/// terms for ANY caller, in addition to the two production call sites
/// that also pre-check before their canonical commit.
/// </summary>
public bool CommitProjection(
ParentAttachmentRelation relation,
Func<uint, ushort?>? resolveParentInstance = null)
{
if (!_stagedByChild.TryGetValue(
relation.ChildGuid,
@ -578,6 +643,9 @@ public sealed class ParentAttachmentState
return false;
}
if (!CanCommitIncarnation(relation, resolveParentInstance))
return false;
RemoveCommittedChild(relation.ChildGuid);
_lastAcceptedByChild[relation.ChildGuid] = relation;
var parent = new ParentIncarnation(
@ -794,6 +862,10 @@ public sealed class ParentAttachmentState
_recoveryByChild.Remove(childGuid);
RemoveCommittedChild(childGuid);
_unresolvedByChild.Remove(childGuid);
// #319 D3/B6: a recycled guid's next incarnation deserves its own
// refusal log rather than silent suppression from a prior, now-dead
// incarnation's latch entry.
_loggedIncarnationRefusals.Remove(childGuid);
}
public void Clear()
@ -812,6 +884,7 @@ public sealed class ParentAttachmentState
foreach (List<uint> children in _committedChildrenByParent.Values)
children.Clear();
_committedChildrenByParent.Clear();
_loggedIncarnationRefusals.Clear();
}
private void RemoveDeferredChildCreates(uint childGuid)

View file

@ -1528,7 +1528,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
// @0x00515AC6 parent = ...; @0x00515AD1 if (parent->cell != 0)
// @0x00515AD6 change_cell(this, parent->cell). The committed
// relation (not the guid alone) resolves the parent so a stale or
// superseded incarnation can never re-cell the child.
// superseded incarnation can never re-cell the child. (Retail
// review R4, 2026-08-05: this protection is ACTIVE for the
// ParentEvent producer, whose incarnation the wire names and
// AP-132 gates on staleness. For the CreateObject producer, #319's
// fix makes the committed relation's incarnation always equal the
// parent's live value at commit time by construction - the
// commit-time tripwire in ParentAttachmentState.CanCommitIncarnation
// enforces it - so this sentence is vacuous-by-design there, not a
// live protection against a reachable staleness.)
if (Entities.ParentAttachments.TryGetCommittedParent(
canonical.ServerGuid,
out uint parentGuid,

View file

@ -405,9 +405,20 @@ public sealed class RuntimeLiveEntitySessionController
return false;
}
// #319 A1 (architecture review, 2026-08-05): same ordering fix as
// the graphical host's PrepareAndTryRealize - the incarnation
// tripwire must run before TryCommitParent's canonical mutation,
// never after, so a mismatch refuses cleanly instead of tearing the
// transaction.
if (!relations.CanCommitIncarnation(staged, _resolveParentInstance))
{
relations.RejectProjection(staged);
return false;
}
ulong positionAuthorityVersion = canonical.PositionAuthorityVersion;
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|| !relations.CommitProjection(staged))
|| !relations.CommitProjection(staged, _resolveParentInstance))
{
return false;
}

View file

@ -711,12 +711,18 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Equal(parent.ServerGuid, snapshot.ParentGuid);
Assert.Null(snapshot.Position);
Assert.False(fixture.Live.TryGetRecord(child.ServerGuid, out _));
// #319: the parent was spawned with generation 1 - the committed
// relation must carry the PARENT's live incarnation (late-bound at
// accept time), not a hardcoded 0. This assertion previously pinned
// the exact #319 defect (it passed for a nonzero-incarnation parent
// only because AcceptCreateObjectRelation ignored the parent
// entirely and always staged 0).
var relation = new ParentAttachmentRelation(
parent.ServerGuid,
child.ServerGuid,
0,
0,
0,
1,
1);
Assert.True(fixture.Live.ParentAttachments.IsCommitted(relation));
@ -763,12 +769,15 @@ public sealed class EquippedChildProjectionWithdrawalTests
fixture.Live.RegisterLiveEntity(childSpawn);
fixture.Controller.OnSpawn(childSpawn);
// #319: the parent was spawned with generation 1 - see the sibling
// fix note in NoPositionCreateParent_CommitsAfterParentPartArrayValidation
// above.
var expected = new ParentAttachmentRelation(
parent.ServerGuid,
childSpawn.Guid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: 0,
ParentInstanceSequence: 1,
ChildPositionSequence: 0);
Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected));
fixture.Poses.Publish(parent.WorldEntity!, Array.Empty<Matrix4x4>());
@ -780,6 +789,222 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.NotNull(child.WorldEntity);
}
/// <summary>
/// #319 F1, the key itself. Dual-parent-class matrix: a player-range
/// parent (nonzero incarnation) and a creature-range parent (sequence
/// 0). Drives the REAL producer (<see cref="EquippedChildRenderController.OnSpawn"/>)
/// rather than the test helper <c>CommitRenderedRelation</c> most other
/// tests in this file use (which hand-crafts the relation and never
/// exercises the hardcoded-0 bug). Verifies the relation commits under
/// the PARENT's exact live incarnation (not 0) and that both D1 (attach
/// re-cell) and D2 (crossing propagation) reach the child.
/// </summary>
[Theory]
[InlineData(0x50000777u, (ushort)9)]
[InlineData(0x70000280u, (ushort)0)]
public void OnSpawn_CreateObjectRelation_LateBindsToParentLiveIncarnationAndPropagatesCell(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
Assert.NotEqual(0u, parent.Canonical.FullCellId);
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
0x7000028Fu,
generation: 1);
childSpawn = childSpawn with
{
Position = null,
ParentGuid = parent.ServerGuid,
ParentLocation = 0,
PlacementId = null,
PositionSequence = 0,
Physics = childSpawn.Physics!.Value with
{
Position = null,
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
AnimationFrame = null,
},
};
fixture.Live.RegisterLiveEntity(childSpawn);
// SABOTAGE PROBE: restoring the literal `ParentInstanceSequence: 0`
// at EquippedChildRenderController.OnSpawn makes the player row
// (parentIncarnation=9) fail this exact assertion while the
// creature row (parentIncarnation=0) keeps passing - the precise
// #319 signature.
fixture.Controller.OnSpawn(childSpawn);
Assert.True(fixture.Live.ParentAttachments.TryGetCommittedParent(
childSpawn.Guid,
out uint committedParentGuid,
out ushort committedInstance));
Assert.Equal(parent.ServerGuid, committedParentGuid);
Assert.Equal(parentIncarnation, committedInstance);
// D1: attach re-cells the child to the parent's exact cell, inside
// the same synchronous OnSpawn call (CommitAcceptedParentCellless
// runs regardless of whether the render projection itself is ready
// to materialize).
Assert.True(fixture.Live.TryGetCanonical(
childSpawn.Guid,
out RuntimeEntityRecord childCanonical));
Assert.Equal(parent.Canonical.FullCellId, childCanonical.FullCellId);
// D2: a later parent canonical-cell write propagates to the
// committed child.
const uint newCell = 0x01020001u;
const uint newLandblock = 0x0102FFFFu;
fixture.Spatial.AddLandblock(new LoadedLandblock(
newLandblock, new LandBlock(), Array.Empty<WorldEntity>()));
Assert.True(fixture.EntityObjects.CommitRebucket(
parent.Canonical, newCell, newLandblock));
Assert.Equal(newCell, childCanonical.FullCellId);
}
/// <summary>
/// #319 A1 (architecture review, 2026-08-05), the ordering fix's own
/// end-to-end proof. Dual-parent-class matrix. A relation whose named
/// incarnation mismatches the parent's live value must be refused
/// BEFORE the canonical commit runs, not after - the original shape
/// let <c>CommitStagedParent</c> (the canonical half: nulls the
/// child's snapshot Position, writes ParentGuid, advances POSITION_TS)
/// run first, then threw from inside <c>CommitProjection</c>, leaving
/// the child canonically parented with no committed relation. Stages
/// the mismatch directly (the real producer cannot construct one - it
/// always resolves the parent's TRUE live incarnation) to isolate the
/// ordering guarantee itself.
/// </summary>
[Theory]
[InlineData(0x50000821u, (ushort)6)]
[InlineData(0x70000299u, (ushort)0)]
public void PrepareAndTryRealize_MismatchedIncarnation_RefusesBeforeCanonicalCommit(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
const uint childGuid = 0x700002A0u;
fixture.RegisterOnly(childGuid, generation: 1, hasPosition: true);
// #319 B1 (architecture review round 2, 2026-08-05): ChildPositionSequence
// MUST equal the fixture's actual gate value (RegisterOnly/SpawnData
// leaves Timestamps.Position and the top-level PositionSequence at 0,
// and unlike NoPositionCreateParent_CommitsAfterParentPartArrayValidation
// this test never calls TryApplyCreateParent/TryApplyParent to advance
// it). A mismatched value here would make TryCommitParent's OWN
// POSITION_TS gate (InboundPhysicsStateController.cs:299-312) refuse
// the canonical commit regardless of ordering, so the three snapshot
// assertions below would pass vacuously under a REVERTED ordering too
// - the test would "fail under sabotage" only on the unrelated staged-
// projection assertion, proving nothing about A1's tearing fix.
var wrongRelation = new ParentAttachmentRelation(
parent.ServerGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(parentIncarnation + 1),
ChildPositionSequence: 0);
fixture.Live.ParentAttachments.AcceptCreateObjectRelation(wrongRelation);
fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid);
// The canonical layer must NEVER observe this child as parented -
// if the ordering regressed, snapshot.Position would be null and
// ParentGuid would be set even though no relation ever committed.
Assert.True(fixture.Live.TryGetSnapshot(
childGuid,
out WorldSession.EntitySpawn snapshot));
Assert.Null(snapshot.ParentGuid);
Assert.NotNull(snapshot.Position);
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
// The refused relation does not linger and block Resolve forever -
// it is rejected, not stranded.
Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection(
childGuid,
out _));
}
/// <summary>
/// #319 A6 (architecture review, 2026-08-05). A prior revision of this
/// fix deferred a CreateObject-carried relation whose parent was not
/// yet addressable through a queue. Both reviews independently proved
/// that shape is structurally unreachable in production for BOTH
/// producers (raw CreateObject via <c>OnSpawn</c> AND the same-
/// generation <see cref="CreateParentUpdate"/> envelope via
/// <c>OnCreateParentAccepted</c>):
/// <c>RuntimeEntityObjectLifetime.RegisterEntityCore</c>'s
/// <c>EnqueueDeferredCreate</c> gate defers the ENTIRE CreateObject
/// whenever its parent is not yet active, and does so BEFORE either
/// wire shape is ever produced - the earlier version of this test
/// only reached the deferred branch by calling
/// <c>OnCreateParentAccepted</c> directly, bypassing that upstream
/// gate. The deferral queue carried three independent defects (a
/// missing child POSITION_TS gate, a placeholder-incarnation collision
/// with the generation filters, unbounded accumulation) while never
/// being exercised by production routing, so it was removed rather
/// than fixed in place. This test now pins the CORRECTED behavior: an
/// unaddressable parent at accept time is refused outright (logged,
/// no state mutation, no crash) - dual-parent-class matrix.
/// </summary>
[Theory]
[InlineData(0x50000811u, (ushort)4)]
[InlineData(0x70000291u, (ushort)0)]
public void OnCreateParentAccepted_ParentNotYetKnown_RefusesWithoutStateOrCrash(
uint parentGuid,
ushort parentIncarnation)
{
using var fixture = new ControllerFixture((_, _, _) =>
new ExactProjectionWithdrawalOutcome(
ExactProjectionWithdrawalDisposition.Completed,
Failure: null));
const uint childGuid = 0x7000029Fu;
fixture.RegisterOnly(childGuid, generation: 1, hasPosition: false);
fixture.CompleteFirstEntry();
var update = new CreateParentUpdate(
childGuid,
parentGuid,
ParentLocation: 0,
PlacementId: 0,
ChildInstanceSequence: 1,
ChildPositionSequence: 1);
// The parent has not spawned yet - the relation must not be staged,
// queued, or committed under any key, and the call must not throw.
Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
fixture.Controller.OnCreateParentAccepted(update);
Assert.False(fixture.Live.ParentAttachments.TryGetStagedProjection(
childGuid,
out _));
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
Assert.Equal(0, fixture.Live.ParentAttachments.UnresolvedRelationCount);
// The parent later becoming addressable does NOT retroactively
// attach the refused relation - there is nothing left to retry
// (the earlier deferred-queue design would have found and
// committed it here; the corrected design does not).
LiveEntityRecord parent = fixture.Spawn(parentGuid, generation: parentIncarnation);
fixture.Controller.OnWorldEntityRegistered(parent.ServerGuid);
Assert.False(fixture.Live.ParentAttachments.TryGetCommittedParent(
childGuid,
out _,
out _));
}
[Fact]
public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange()
{

View file

@ -127,6 +127,77 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(1, fixture.Ready.PublishCount);
}
/// <summary>
/// #319 F2 (contract §3.1). Dual-parent-class matrix: a player-range
/// parent with a nonzero incarnation and a creature/static-range parent
/// at sequence 0. #319 existed precisely because every pre-existing
/// test in this file used only sequence-0 (non-player) parents - a
/// matrix in which the two classes could diverge silently is the
/// defect's habitat.
/// </summary>
[Theory]
[InlineData(0x50000123u, (ushort)7)]
[InlineData(0x70000099u, (ushort)0)]
public void CommittedChild_IsExcludedFromLandblockLoadCandidates(
uint parentGuid,
ushort parentInstanceSequence)
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
// Simulate the post-#319-fix state: a committed parent relation
// exists for this child (D1/D2, exercised elsewhere, would give it
// a nonzero canonical cell equal to the parent's). Pre-fix the
// relation DID commit for a player-class parent too - HasCommittedParent
// is child-keyed and CommitProjection succeeded under (playerGuid, 0)
// (retail review R3).
//
// The two InlineData rows differ in what was reachable PRE-fix
// (retail review round 2, D2 - qualified per parent class):
// - PLAYER row (0x50000123u): the relation committed under the
// WRONG key (playerGuid, 0), so D1's `parent.Incarnation ==
// parentInstanceSequence` gate never matched and the child's
// FullCellId stayed 0 forever pre-fix - this candidate gate
// this test exercises never saw a live population for this row
// before the fix.
// - CREATURE row (0x70000099u): the hardcoded 0 genuinely MATCHES
// a creature-class parent's real incarnation, so D1 already
// passed pre-fix and this child ALREADY carried a nonzero
// FullCellId at HEAD - this row's scenario was already LIVE
// pre-fix, and the child was already a hydration candidate
// taking the legacy RebucketLiveEntity path this gate now
// excludes (contract §3.1's correction). Both rows exercise the
// SAME gate; only their pre-fix reachability differs.
var relation = new ParentAttachmentRelation(
parentGuid,
Guid,
ParentLocation: 0,
PlacementId: 0,
parentInstanceSequence,
ChildPositionSequence: 1);
fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation);
Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation));
// Decoy: the legacy RebucketLiveEntity branch below would overwrite
// this back to the canonical cell (0x01010001u, matching `Cell`) if
// this candidate were not excluded - see
// LiveEntityRuntime.RebucketLiveEntity's unconditional
// `entity.ParentCellId = spatialCellOrLandblockId` write.
entity.ParentCellId = 0x02020002u;
fixture.Controller.OnLandblockLoaded(Cell);
// #319 F2: a committed child is never independently re-placed by
// landblock load (retail's update_object `parent != 0` early-out,
// @0x00515D40) - the decoy must survive untouched. Sabotage:
// deleting the `HasCommittedParent` gate in
// LiveEntityHydrationController.OnLandblockLoaded makes this fail
// for BOTH rows (the decoy gets corrected back to 0x01010001u).
Assert.Equal(0x02020002u, entity.ParentCellId);
}
[Fact]
public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady()
{

View file

@ -6,6 +6,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -78,6 +79,71 @@ public sealed class LiveEntityPresentationControllerTests
Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity);
}
/// <summary>
/// #319 F2 (contract §3.2). Dual-parent-class matrix: a player-range
/// parent with a nonzero incarnation and a creature/static-range parent
/// at sequence 0. Neither <c>IsSpatiallyProjected</c> nor
/// <c>IsSpatiallyVisible</c> excludes a committed child (its
/// presentation-only rebucket sets <c>IsSpatiallyProjected</c> true
/// every frame), so the Hidden -&gt; Visible edge must not install a
/// shadow row for it - route 7's P4 record: a committed child never
/// owns an independent broadphase row.
/// </summary>
[Theory]
[InlineData(0x50000456u, (ushort)3)]
[InlineData(0x70000199u, (ushort)0)]
public void CommittedChild_HiddenThenVisible_NeverInstallsShadowRow(
uint parentGuid,
ushort parentInstanceSequence)
{
Fixture fixture = new(
PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions);
// Simulate the post-#319-fix state: a committed parent relation
// exists for this child, giving it a nonzero canonical FullCellId
// via D1/D2 (exercised elsewhere). Pre-fix the relation DID commit
// for a player-class parent too (HasCommittedParent is child-keyed;
// retail review R3).
//
// Per-row pre-fix reachability (retail review round 2, D2):
// - PLAYER row (0x50000456u): the relation committed under the
// WRONG key, D1 never matched, FullCellId stayed 0 forever
// pre-fix - RestoreShadow's `FullCellId == 0` clause already
// refused this row's population at HEAD.
// - CREATURE row (0x70000199u): the hardcoded 0 matches a
// creature-class parent's real incarnation, so D1 already
// passed pre-fix and this child ALREADY carried a nonzero
// FullCellId at HEAD - RestoreShadow's `FullCellId == 0` clause
// did NOT refuse this row pre-fix (architecture review A4); the
// `HasCommittedParent` clause added by this fix is what now
// refuses it, a live behavior change for this class.
var relation = new ParentAttachmentRelation(
parentGuid,
Fixture.Guid,
ParentLocation: 0,
PlacementId: 0,
parentInstanceSequence,
ChildPositionSequence: 1);
fixture.Runtime.ParentAttachments.AcceptCreateObjectRelation(relation);
Assert.True(fixture.Runtime.ParentAttachments.CommitProjection(relation));
Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid));
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Runtime.TryApplyState(
new SetState.Parsed(Fixture.Guid, 0u, 1, 2),
out _,
out _));
Assert.True(fixture.Controller.OnStateAccepted(Fixture.Guid));
// #319 F2: sabotage - deleting the `HasCommittedParent` gate in
// LiveEntityPresentationController.RestoreShadow makes this fail
// for BOTH rows (TotalRegistered becomes 1, the #184 shape: an
// invisible-but-solid weapon row).
Assert.Equal(0, fixture.Shadows.TotalRegistered);
Assert.True(fixture.Entity.IsDrawVisible);
}
[Fact]
public void SpellRecall_HiddenAndUnHide_RetireMagicTimelineThroughController()
{

View file

@ -35,6 +35,223 @@ public sealed class ParentAttachmentStateTests
Assert.Equal(parentGuid, child.ParentGuid);
}
/// <summary>
/// #319 A1 (architecture review, 2026-08-05). Dual-parent-class matrix:
/// <see cref="ParentAttachmentState.CanCommitIncarnation"/> is a PURE,
/// side-effect-free precondition - it must refuse a mismatch without
/// mutating ANY state, so a caller can check it before either half of a
/// commit runs (never a throw; the original shape threw from inside
/// <see cref="ParentAttachmentState.CommitProjection"/>, reached only
/// after the canonical commit had already landed, tearing the
/// transaction).
/// </summary>
[Theory]
[InlineData(0x50000210u, (ushort)5)]
[InlineData(0x70000211u, (ushort)0)]
public void CanCommitIncarnation_MismatchedIncarnation_RefusesWithoutMutatingState(
uint parentGuid,
ushort liveParentIncarnation)
{
const uint childGuid = 0x70000212u;
var relations = new ParentAttachmentState();
var wrongRelation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(liveParentIncarnation + 1),
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(wrongRelation);
Assert.False(relations.CanCommitIncarnation(
wrongRelation,
guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null));
// Purely a read - the relation is untouched, still staged, no
// committed parent exists.
Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged));
Assert.Equal(wrongRelation, staged);
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
// A NULL resolver (parent not currently addressable) must not
// refuse - the tripwire only fires "whenever the parent is
// active" (contract F1).
Assert.True(relations.CanCommitIncarnation(wrongRelation, _ => null));
}
/// <summary>
/// #319 A1. <see cref="ParentAttachmentState.CommitProjection"/> itself
/// must be non-tearing for ANY caller (not just the two production call
/// sites that also pre-check): a mismatch returns false and mutates
/// nothing, never throws.
/// </summary>
[Theory]
[InlineData(0x50000213u, (ushort)5)]
[InlineData(0x70000214u, (ushort)0)]
public void CommitProjection_MismatchedIncarnation_ReturnsFalseWithoutThrowingOrMutating(
uint parentGuid,
ushort liveParentIncarnation)
{
const uint childGuid = 0x70000215u;
var relations = new ParentAttachmentState();
var wrongRelation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
ParentInstanceSequence: (ushort)(liveParentIncarnation + 1),
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(wrongRelation);
Assert.False(relations.CommitProjection(
wrongRelation,
guid => guid == parentGuid ? liveParentIncarnation : (ushort?)null));
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
Assert.True(relations.TryGetStagedProjection(childGuid, out ParentAttachmentRelation staged));
Assert.Equal(wrongRelation, staged);
// A matching resolver commits normally.
Assert.True(relations.CommitProjection(
wrongRelation,
guid => guid == parentGuid
? (ushort)(liveParentIncarnation + 1)
: (ushort?)null));
Assert.True(relations.TryGetCommittedParent(
childGuid,
out uint committedParent,
out ushort committedInstance));
Assert.Equal(parentGuid, committedParent);
Assert.Equal((ushort)(liveParentIncarnation + 1), committedInstance);
}
/// <summary>
/// #319 contract §6 test 9 (ledger convergence) - BLOCKS per the
/// architecture review, since a stranded/accumulating relation is
/// precisely a ledger-convergence defect. Dual-parent-class matrix.
/// Removing the CHILD must zero every one of
/// <see cref="ParentAttachmentState"/>'s four tables
/// (staged/recovery/committed/unresolved), not just the committed
/// entry.
/// </summary>
[Theory]
[InlineData(0x50000900u, (ushort)3)]
[InlineData(0x70000901u, (ushort)0)]
public void LedgerConvergence_ChildRemoval_ZeroesEveryTable(
uint parentGuid,
ushort parentIncarnation)
{
const uint childGuid = 0x70000902u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
// MarkProjected(Recovery) simulates a completed realize/render
// pass, which clears the retry-candidate bookkeeping - the
// permanent committed record in _lastAcceptedByChild is untouched
// by it, which is the table this test cares about.
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
Assert.Equal(1, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.NotEmpty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
relations.RemoveChild(childGuid);
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Equal(0, relations.UnresolvedRelationCount);
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
Assert.False(relations.TryGetCommittedParent(childGuid, out _, out _));
Assert.False(relations.HasCommittedParent(childGuid));
}
/// <summary>
/// #319 contract §6 test 9, the PARENT-removal companion. Dual-parent-
/// class matrix. Removing the PARENT must converge the committed
/// child's entry via <see cref="ParentAttachmentState.RemoveObject"/>'s
/// parent-reference sweep.
/// </summary>
[Theory]
[InlineData(0x50000920u, (ushort)2)]
[InlineData(0x70000921u, (ushort)0)]
public void LedgerConvergence_ParentRemoval_ZeroesEveryTable(
uint parentGuid,
ushort parentIncarnation)
{
const uint childGuid = 0x70000922u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
childGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
relations.RemoveObject(parentGuid);
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
Assert.False(relations.HasCommittedParent(childGuid));
}
/// <summary>
/// #319 contract §6 test 9, full-teardown companion. Dual-parent-class
/// matrix. <see cref="ParentAttachmentState.Clear"/> must converge every
/// table to zero with a player-parented committed child AND a still-
/// unresolved ParentEvent present simultaneously - the mid-session
/// accumulation shape the architecture review's A5 finding was
/// concerned with (now moot for the deleted deferred-CreateObject
/// queue, but the pre-existing ParentEvent `_unresolvedByChild` queue
/// this fix left untouched still needs its own convergence proof).
/// </summary>
[Theory]
[InlineData(0x50000930u, (ushort)9)]
[InlineData(0x70000931u, (ushort)0)]
public void LedgerConvergence_Teardown_ZeroesEveryTableWithMixedPendingState(
uint parentGuid,
ushort parentIncarnation)
{
const uint committedChildGuid = 0x70000932u;
const uint unresolvedChildGuid = 0x70000933u;
var relations = new ParentAttachmentState();
var relation = new ParentAttachmentRelation(
parentGuid,
committedChildGuid,
ParentLocation: 0,
PlacementId: 0,
parentIncarnation,
ChildPositionSequence: 1);
relations.AcceptCreateObjectRelation(relation);
Assert.True(relations.CommitProjection(relation));
relations.MarkProjected(relation, ParentProjectionCandidateKind.Recovery);
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, unresolvedChildGuid, 0, 0, parentIncarnation, 1));
Assert.True(relations.CommittedRelationCount > 0);
Assert.True(relations.UnresolvedRelationCount > 0);
relations.Clear();
Assert.Equal(0, relations.CommittedRelationCount);
Assert.Equal(0, relations.RecoveryRelationCount);
Assert.Equal(0, relations.StagedRelationCount);
Assert.Equal(0, relations.UnresolvedRelationCount);
Assert.False(relations.HasCommittedParent(committedChildGuid));
Assert.Empty(relations.ChildrenAttachedToParent(parentGuid, parentIncarnation));
}
[Fact]
public void MultipleQueuedRelationsRemainOrderedAndNewestAcceptedWins()
{

View file

@ -252,6 +252,40 @@ public sealed class RuntimeEntityChildCellPropagationTests
Assert.Equal(0u, grandchild.FullCellId);
}
/// <summary>
/// #319 route-7 invariant 2 re-run, dual-parent-class. The propagation
/// mechanism itself (D1/D2/D3) was always guid-range-agnostic - these
/// tests construct relations directly via <see cref="CommitAttachment"/>
/// rather than through the buggy producer - but #319 exists precisely
/// because no existing test in this file used a PLAYER-range parent
/// guid. Closes that gap for the removal path (AP-142 clause (a)): the
/// key fix must not perturb withdrawal for either parent class.
/// </summary>
[Theory]
[InlineData(0x50000601u, (ushort)8)]
[InlineData(0x70040609u, (ushort)0)]
public void Withdrawal_PickupOfParent_ZeroesCommittedChildren_DualParentClass(
uint parentGuid,
ushort parentInstance)
{
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
Bind(lifetime, 1UL);
const uint childGuid = 0x7004060Au;
lifetime.RegisterEntity(Spawn(parentGuid, parentInstance));
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
Assert.True(CommitAttachment(lifetime, parentGuid, parentInstance, childGuid, 2));
Assert.True(lifetime.Entities.TryGetActive(
childGuid, out RuntimeEntityRecord child));
Assert.NotEqual(0u, child.FullCellId);
Assert.True(lifetime.TryApplyPickup(
new PickupEvent.Parsed(parentGuid, InstanceSequence: parentInstance, PositionSequence: 2),
acknowledgeProjection: null,
out _));
Assert.Equal(0u, child.FullCellId);
}
[Fact]
public void Withdrawal_CommitWithdrawalOfParent_ZeroesCommittedChildren()
{