From 1b484937b6f2ca2c8ae0b6a98aeb815f2382bfe3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 16:45:57 +0200 Subject: [PATCH] =?UTF-8?q?test(physics):=20C4=20route=206=20=E2=80=94=20d?= =?UTF-8?q?rops/split-recovery=20closure,=20zero=20production=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 6 needs no production change and this commit contains none: C3c (529e0e9d) already flipped both hosts' Create paths onto the residence lease, so a dropped item is byte-for-byte route 1's create classification (RuntimePositionEntityKind.Remote, RuntimeCreateResidenceKind.TopLevel, ClassifyCreate -> SetPosition with InitialCreateFlags = Placement | Slide). Route 6 is a SOURCE of route-1 traffic, not a route of its own. Both drop flavours converge on LiveEntityHydrationController.OnCreate -> RegisterEntityWithInitialResidence — the whole-item drop through ItemInteractionController's DropToWorld (no physics, no position; the server decides), and split-to-world through TryRecoverUnknownPosition's call to the identical entry point. Contract: docs/research/2026-08-04-c4-route-6-contract.md. Retires a FALSE PREMISE from the campaign plan (:97-100), which claimed split-recovery creates "need an effect-replay suppression signal". Verified against the decomp instead of assumed: play_default_script @0x005132B0 / @0x00513300 has exactly three call sites in the entire pseudo-C dump — DefaultScriptPartHook::Execute @0x00526c08, DefaultScriptHook::Execute @0x00526c14, and ACCWeenieObject::DoCollision @0x0058c3b4 — and NONE from set_description or CreateObject. Neither client plays a default script at create, so there is nothing to suppress. acdream's only create-time replay is the F754/F755 queue drain keyed by server GUID, which is retail's own HandleCreateObject @0x00454C80 behaviour. The plan's other two clauses were closed at C0 (TryCommitParent/CommitWithdrawal cancellation symmetry; host-visible cancellation receipts); the list now states what actually remains — route 7's child-cell two-writer split and the headless parent-realize gap. Retail split marking recorded for the record: UIAttemptSplitTo3D @0x0058D850 stores only splitStackSize/splitClassID/splitTime and performs no placement; DeclareValid @0x0058E340's recovery action is SetSelectedObject @0x0058E481 — a SELECTION transfer with a 10-second expiry, not effect suppression and not placement. UIAttemptPutIn3D @0x0058D700 records no marker at all. Seven tests over the now-flipped path (whole item, split stack, new-GUID recovery, second drop, unavailable destination, newer Position after the pending identity is consumed, plus the #314 repro), each sabotage-verified: the production path was broken on purpose, the test was confirmed to fail, and the sabotage reverted. R6-c is now settled by assertion rather than argument — BuildSpawn's wholesale clone of Children/Movement/AnimationFrame/ SetupTableId is measured, not reasoned about. FOUND WHILE TESTING — #314, filed not fixed (this route is zero-production by contract). BuildSpawn resets top-level MovementSequence/ServerControlSequence to 0 but its Timestamps `with` block overrides only Position/Teleport/ ForcePosition/Instance, leaving Physics.Timestamps.Movement and .ServerControlledMove at the SOURCE item's values. HasConsistentCreateIdentityAndParent requires the two projections to agree, so a split whose source carries nonzero Movement timestamps — plausible for any item dropped once, picked up, and split again — fails the predicate and throws instead of completing the canonical transaction. Verified in source, not taken on report. Note this is a crash in the exact mechanism the scoping cited as EVIDENCE that drops already converge: code reading said the path converges, driving it said it throws. Fixed in the immediately following commit. Also filed: #313 (DeclareValid's SetSelectedObject port is missing and the container-split flavour records no marker — selection UX, deliberately not implemented inside a placement closure) and #315 (route 4b-3's per-packet runTeleportHook Func closure at three RunRemoteArmTail call sites; the network packet path, not Slice I's per-frame resolve path — filed now because route 5 adds a fourth site). AP-124 stays open and registered. Test lines are 410 against a 150-250 guidance, accepted: the excess is a real ItemInteractionController harness plus the #314 repro, which is what found the defect. A mock that proved nothing would have been shorter and worthless. Complete Release suite MEASURED at 11,020 passed / 4 skipped / 0 failed (baseline 11,013/4/0 at 6dc7ba51; +7 new). Neither known flake fired. Connected gate (user-run) still owed: drop a whole item, split a stack to the ground, drop a second within ~1 m, repeat indoors and after a portal recall, then walk two landblocks away and back. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 141 +++++++ docs/plans/2026-08-02-placement-cutover.md | 23 +- .../2026-08-04-c4-route-6-contract.md | 124 ++++++ .../LiveEntityHydrationControllerTests.cs | 364 ++++++++++++++++++ .../PendingSplitToWorldProjectionTests.cs | 46 +++ 5 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 docs/research/2026-08-04-c4-route-6-contract.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e274f3de..c1c89b9b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,147 @@ 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. +## C4 route 6 — drops and split-recovery closure — 2026-08-04 + +#313 and #314 filed from the route 6 closure session (zero production lines; +evidence + coverage tests only). #315 filed from the same session, carried +over from the route 4b-3 round-2 reviews. Evidence: +[`2026-08-04-c4-route-6-contract.md`](research/2026-08-04-c4-route-6-contract.md), +[`2026-08-04-c4-routes-6-7-scoping.md`](research/2026-08-04-c4-routes-6-7-scoping.md). + +## #313 — `DeclareValid`'s `SetSelectedObject` split-recovery is not ported + +**Status:** OPEN +**Severity:** LOW (selection UX, not placement) +**Filed:** 2026-08-04 +**Component:** UI / inventory / selection + +**Description:** Retail's `ACCWeenieObject::DeclareValid @0x0058E340` reads +the split marker recorded by `UIAttemptSplitTo3D @0x0058D850` / +`UIAttemptSplitToContainer @0x0058D7D0` (three fields: `splitStackSize`, +`splitClassID`, `splitTime`) and, on a matching WCID + stack-size within the +10-second window, runs `ACCWeenieObject::SetSelectedObject(this->id, 0)` +@0x0058E481 — a SELECTION transfer to the newly-materialized split result, +not effect suppression and nothing placement-related (verified against +`acclient_2013_pseudo_c.txt`; see +`docs/research/2026-08-04-c4-route-6-contract.md`). acdream's +`PendingSplitToWorldProjection` +(`src/AcDream.App/World/InventoryWorldDropProjectionController.cs`) +implements the 10-second recognition window (`RetailRecognitionSeconds`) but +has no selection dependency at all — the split result never becomes the +selected object after a ground split, and the container-split flavor +(`UIAttemptSplitToContainer`'s equivalent) records no marker at all. + +**Root cause:** `InventoryWorldDropProjectionController`'s constructor takes +interaction / objects / runtime / hydration / clock and nothing selection- +related; `PendingSplitToWorldProjection.TryResolve` never calls anything +resembling `SetSelectedObject`. + +**Files:** `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`. +Whatever owns "currently selected object" client-side (search for +`selectedObjectId` — `ItemInteractionController` already takes one as a +`Func`, so the write side needs a matching setter/owner). + +**Acceptance:** Split a partial stack to the ground; the newly-created pile +becomes the selected object (matching retail's post-split selection +behavior), with a 10-second recognition window identical to the existing +recovery window. Out of C4 scope — do not implement as part of a placement- +focused change; this is selection UX and mixing it into a placement closure +makes the landing un-reviewable (per the route 6 contract). + +## #314 — Split recovery throws instead of recovering when the source's retained Movement/ServerControlledMove timestamps are nonzero + +**Status:** OPEN +**Severity:** MEDIUM (can turn a normal split-to-ground into a client +exception instead of a placed item) +**Filed:** 2026-08-04 +**Component:** physics / inventory / entity lifetime + +**Description:** Discovered while writing C4 route 6's "split stack" / "new +GUID recovery" coverage tests +(`tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs`, +`SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering`). +`PendingSplitToWorldProjection.BuildSpawn` +(`src/AcDream.App/World/InventoryWorldDropProjectionController.cs:171-209`) +resets the top-level `MovementSequence` / `ServerControlSequence` to `0` +(`:201-202`) when constructing the synthetic spawn for the new split-result +GUID, but its `Physics.Timestamps` override list only touches `Position` / +`Teleport` / `ForcePosition` / `Instance` (`:182-188`) — it does NOT reset +`Physics.Timestamps.Movement` / `.ServerControlledMove` to match. Those two +fields instead retain the SOURCE item's original values verbatim. + +`RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent` +(`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:2321-2327`) +requires the flattened top-level sequence fields to agree exactly with the +embedded `PhysicsSpawnData.Timestamps` — by design, since they are two +projections of the same wire packet +(`RegisterEntityCore` throws `"CreateObject 0x{guid} has inconsistent +instance or parent projections."` at `:748-749` when they disagree). Retail's +per-object `update_times` timestamp channels are monotonic counters that do +NOT reset when an item re-enters a container, so any split source that ever +received a Movement or ServerControlledMove wire update during an earlier +stint with world presence (e.g. dropped once before, picked back up, split +again) carries nonzero values in exactly the two fields `BuildSpawn` forgets +to reset. The split recovery then throws `InvalidOperationException` instead +of completing the canonical create-placement transaction, inside +`InventoryWorldDropProjectionController.TryRecoverUnknownPosition` — an +unhandled exception on the ordinary network/UI event path. + +**Root cause:** Asymmetric field reset in `BuildSpawn`'s two `with` +expressions — the top-level projection and the embedded `PhysicsSpawnData` +projection of the same synthetic spawn are constructed independently and +fell out of sync. + +**Fix shape (not applied — C4 route 6 is a zero-production-line closure by +contract):** either also reset `Timestamps.Movement` / `.ServerControlledMove` +to `0` in `BuildSpawn`'s `Timestamps with { ... }` block, or don't reset the +top-level `MovementSequence` / `ServerControlSequence` at all and let them +inherit the source's values instead (whichever direction is retail-correct +needs a decompiled cross-check of what `UIAttemptSplitTo3D`'s resulting +CreateObject actually carries for these two channels — not established by +this filing). + +**Files:** `src/AcDream.App/World/InventoryWorldDropProjectionController.cs:182-188,200-207`; +consumed by `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:2321-2327`. + +**Acceptance:** Split a stack of an item whose weenie has previously been +dropped to the ground and picked back up (so its retained Movement / +ServerControlledMove timestamp channels are nonzero) a second time; the +split result places normally instead of throwing. + +## #315 — `runTeleportHook` builds a `Func` closure per network packet + +**Status:** OPEN +**Severity:** LOW (real allocation regression, not correctness; not on the +per-frame resolve path Slice I's 0 B/resolve discipline governs) +**Filed:** 2026-08-04 +**Component:** physics / networking + +**Description:** Carried over from the C4 route 4b-3 round-2 architecture +reviews (both said defer, but flagged that route 5 will add a fourth call +site once it lands). Three `RunRemoteArmTail` call sites currently in +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` each build a +`Func` delegate per inbound packet to pass into +`ApplyRemoteContactRouting`. This is a real allocation regression versus the +`3e002993` baseline, on the 5-10 Hz network packet path — not the per-frame +physics resolve path Slice I's zero-allocation discipline covers, so it did +not show up in that gate. + +**Root cause:** `ApplyRemoteContactRouting`'s public signature takes a +`Func` parameter, and existing tests inject lambdas into it directly — +changing the signature to a non-allocating shape (a struct callback, a +cached delegate, or an explicit two-phase call) touches test call sites +across the file, which is why both round-2 reviews deferred it rather than +fixing it inline. + +**Files:** `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` — +`RunRemoteArmTail` call sites feeding `ApplyRemoteContactRouting`. + +**Acceptance:** The three (four, once route 5 lands) `RunRemoteArmTail` call +sites do not allocate a fresh delegate per packet; existing +`ApplyRemoteContactRouting` tests continue to pass, updated for whatever +non-allocating shape replaces the `Func` parameter. + ## C4 route 4b-1 review — park lifecycle — 2026-08-04 #309 and #310 filed from the route 4b-1 dual-review round; #311 filed from diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 8405cb06..515a4686 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -94,10 +94,25 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. - The exact-Setup mover chain (`PrepareMover` / `RuntimeSetPositionMoverPreparer.TryBuild` / `IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired. -- Route-6 split-recovery creates need an effect-replay suppression signal; - route-7 needs `TryCommitParent`/`CommitWithdrawal` cancellation-symmetry - fixes and host-visible cancellation receipts; headless lacks any - parent-realize sequence (pre-existing, adjacent). +- **Corrected 2026-08-04 (C4 route 6 closure, + `docs/research/2026-08-04-c4-route-6-contract.md`): all three clauses + above were stale.** Route-6 split-recovery does NOT need an effect-replay + suppression signal — that premise was unsubstantiated; acdream's only + create-time effect replay is the F754/F755 queue drain keyed by server + GUID, and the one plausible mechanism (a cloned `DefaultScriptType` + surviving `BuildSpawn`) never fires at create in either client + (`CPhysicsObj::play_default_script @0x005132B0`/`@0x00513300` has exactly + two callers, both animation hooks, verified against + `acclient_2013_pseudo_c.txt`). Route-7's `TryCommitParent`/ + `CommitWithdrawal` cancellation-symmetry fixes and host-visible + cancellation receipts were BOTH closed at C0 (see the C0 slice below). + What actually remains for route 7: the child's canonical cell has two + writers (Runtime commits it cell-less unconditionally in + `CommitAcceptedParentCellless`, while `EquippedChildRenderController + .TickChild` re-cells it from a per-frame render tick), and headless has no + `EquippedChildRenderController` at all, so every headless parented child + stays cell-less forever — the same defect seen from two sides, not two + separate gaps. ## Slices diff --git a/docs/research/2026-08-04-c4-route-6-contract.md b/docs/research/2026-08-04-c4-route-6-contract.md new file mode 100644 index 00000000..328c7319 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-6-contract.md @@ -0,0 +1,124 @@ +# C4 route 6 — drops and split-recovery: pinned contract (2026-08-04) + +Scoped in +[`2026-08-04-c4-routes-6-7-scoping.md`](2026-08-04-c4-routes-6-7-scoping.md) +§6. Pinned after route 4b-3 landed (`6dc7ba51`, gate passed `21cd6e9b`). + +**Route 6 requires ZERO production lines.** It is a closure, not a slice: C3c +(`529e0e9d`) already flipped both hosts' Create paths onto the residence lease, +and a dropped item is byte-for-byte route 1's create classification. The +deliverable is evidence + coverage tests + one planning-record correction. + +**If implementing this contract requires a production change, STOP AND REPORT.** +A production diff here means the scoping's central finding is wrong, and that +is a finding worth more than the slice. + +## Why there is nothing to build + +A dropped item is an ordinary non-local CreateObject: +`RuntimePositionEntityKind.Remote`, `RuntimeCreateResidenceKind.TopLevel`, +`ClassifyCreate` → disposition `SetPosition` with +`InitialCreateFlags = Placement | Slide`. Route 6 is a *source* of route-1 +traffic, not a route of its own. + +Both drop flavours already converge on the canonical transaction: + +- **Whole-item drop** — `ItemInteractionController.ExecutePlacementActions`'s + `DropToWorld` case sends the drop with no physics and no position; the + server's CreateObject returns through + `LiveEntityHydrationController.OnCreate` → `RegisterLiveEntity` → + `RegisterEntityWithInitialResidence`. +- **Split-to-world** — `InventoryWorldDropProjectionController`'s + `TryRecoverUnknownPosition` calls **the identical `_hydration.OnCreate` + entry point**. Same residence lease, same conductor, same placement. + +So the campaign handoff's route-6 requirement ("`TryRecoverUnknownPosition` +may create the logical object, but it must enter the same canonical +create-placement transaction") is already met, by C3c, with zero route-6 code. + +## The false premise this closure exists to retire + +`docs/plans/2026-08-02-placement-cutover.md:98-100` states route-6 +split-recovery creates "need an effect-replay suppression signal". **That is +unsubstantiated and must be corrected in this landing.** + +acdream's only create-time effect replay is the F754/F755 queue drain keyed by +server GUID (`EntityEffectController.ReplayPendingForLiveEntity`), written only +by inbound `PlayPhysicsScript`/`PlayPhysicsScriptType` for that exact GUID. A +fresh split GUID has nothing queued unless ACE actually sent an effect for it, +and draining it then is retail's own behaviour +(`SmartBox::HandlePlayScriptID` @0x00452020 / `HandlePlayScriptType` +@0x00452070 queue while absent; `HandleCreateObject` @0x00454C80 drains). + +The one plausible mechanism — a cloned `DefaultScriptType` surviving +`BuildSpawn` — **does not fire at create in either client.** acdream's +`PlayDefault` has exactly two callers, both animation hooks +(`DefaultScriptHook`, `DefaultScriptPartHook`). Retail matches: +`CPhysicsObj::play_default_script` @0x005132B0 / @0x00513300 is reached only +from `ACCWeenieObject::DoCollision` @0x0058C3A0 (call @0x0058C3B4) and the +animation-hook dispatcher (@0x00526C08, @0x00526C14). **Neither client plays a +default script from `set_description` or CreateObject.** + +**Verify both retail claims yourself before relying on them.** + +## Retail truth for split-recovery marking + +`ACCWeenieObject::UIAttemptSplitTo3D` @0x0058D850 records exactly three fields: +`splitStackSize` @0x0058D8A2, `splitClassID` @0x0058D8A8, `splitTime` +@0x0058D8AE. `UIAttemptSplitToContainer` @0x0058D7D0 records the identical +three. The consumer is `ACCWeenieObject::DeclareValid` @0x0058E340, whose +recovery action is `SetSelectedObject(this->id, 0)` @0x0058E481 — **a SELECTION +transfer, with a 10-second expiry @0x0058E49F-@0x0058E4B2. Not effect +suppression, and nothing placement-related.** + +`ACCWeenieObject::UIAttemptPutIn3D` @0x0058D700 (whole-item drop) records no +marker at all and performs no placement. + +## Deliverables + +1. **Coverage tests, 150-250 lines**, against the now-flipped path — the list + the campaign handoff names: whole item, split stack, new GUID, second drop + position, unavailable destination, newer Position arriving while waiting. + Note "attached child becoming a world root" from that list is **NOT route + 6's** — it is a cell-less Position on an existing entity, owned by 4b-3. +2. **R6-c settled by assertion, not by argument.** `BuildSpawn` clones + `Children` / `Movement` / `AnimationFrame` / `SetupTableId` wholesale from + the source. Expected inert for a stackable inventory item but never + measured. Assert it in the tests rather than reasoning about it. +3. **The plan correction** at `docs/plans/2026-08-02-placement-cutover.md:98-100`. + All three clauses are stale: the effect-replay premise is unsubstantiated + (above), and route 7's `TryCommitParent`/`CommitWithdrawal` + cancellation-symmetry and host-visible cancellation receipts were BOTH + closed at C0. Replace with what actually remains: route 7's child-cell + two-writer split and the headless parent-realize gap. +4. **R6-a filed as an issue, NOT implemented here.** `DeclareValid`'s + `SetSelectedObject` is not ported and the container-split flavour has no + marker. That is selection UX, not placement; mixing it into a placement + closure makes the landing un-reviewable. Add to `docs/ISSUES.md`. + +## What must remain true + +- **Zero production lines.** No `src/**` change. See the stop condition above. +- **Tests must fail against broken behaviour.** No source-text pins, no + tautologies. The 4b-3 lesson (round-2 finding B1): a test asserting only what + must NOT happen cannot detect a deleted write — assert the positive half too. +- AP-124 (the WCID/count approximation in the recovery match) stays open and + registered; retiring it needs ACE to send CreateObject to the initiator, not + a client change. + +## Gates + +- Complete Release suite. **Baseline 11,013 passed / 4 skipped / 0 failed** at + `6dc7ba51` — measure and record; do not inherit. Known flakes, do not chase + and do not conflate: #302 (`PortalProjectionTests`, GC-allocation) and #308 + (`NakEmissionTests.LossSoak_…`, wall-clock, full-suite load only). +- **Connected gate (user-run), cheap and directly visible:** drop a whole item + on open ground — it must land at your feet, resting, immediately pickable. + Split a partial stack to the ground — correct quantity on the pile, remainder + in inventory. Drop a second item within ~1 m — both remain visible and + separately pickable. Repeat once indoors and once after a portal recall. Walk + two landblocks away and back — both piles still there, still pickable. + **Regressions:** item at world origin or your *previous* position (stale + pose); invisible but blocking (#184 class); sunk into or floating above the + floor; not pickable; the split pile never appears (recovery window failed); + the second drop swallowed by the first. diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index a0f5c329..ef3d063d 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -3,6 +3,7 @@ using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; +using AcDream.App.UI; using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Net; @@ -10,6 +11,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; namespace AcDream.App.Tests.World; @@ -2038,6 +2040,368 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(1, second.ApplyCount); } + // C4 route 6 (docs/research/2026-08-04-c4-route-6-contract.md): drops and + // split-recovery require zero production lines -- C3c already flipped + // both hosts' Create paths onto the canonical residence lease, and a + // dropped item is byte-for-byte route 1's create classification. These + // tests measure that convergence end-to-end through the same Fixture the + // rest of this file uses, plus a small real ItemInteractionController + // (DropHarness) to drive the actual dispatch surface a player action + // uses. + + [Fact] + public void WholeItemDrop_DispatchesNoSplitMarkerAndEntersCanonicalCreatePlacementTransaction() + { + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint itemGuid = 0x50000B01u; + fixture.Controller.OnCreate(ItemSpawn( + itemGuid, position: null, containerId: DropHarness.PlayerGuid, stackSize: null)); + bool dispatched = false; + drop.Interaction.WorldDropDispatched += _ => dispatched = true; + var payload = new ItemDragPayload( + itemGuid, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot()); + + Assert.True(drop.Interaction.DropToWorld(payload)); + + // Retail's UIAttemptPutIn3D @0x0058D700 records no split marker for a + // whole-item drop (only UIAttemptSplitTo3D / UIAttemptSplitToContainer + // do); the split-recovery machinery must stay dormant. + Assert.False(dispatched); + + // The server's CreateObject for the dropped item returns through the + // ordinary wire pump -- model that directly, bypassing the (dormant) + // split projection entirely, and confirm it lands in the same + // canonical create-placement transaction as any other TopLevel + // create (RuntimeInitialCreateResidenceState.cs:600-606). A fresh + // world-visible incarnation carries a new InstanceSequence (retail's + // instance_ts): the item's contained CreateObject and its + // now-dropped CreateObject are not the same generation. + fixture.Controller.OnCreate(ItemSpawn( + itemGuid, + position: new CreateObject.ServerPosition(Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f), + containerId: 0u, + stackSize: null, + instanceSequence: 2)); + + Assert.True(fixture.Runtime.TryGetRecord(itemGuid, out LiveEntityRecord record)); + Assert.NotNull(record.WorldEntity); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering() + { + // DISCOVERED DEFECT (2026-08-04, C4 route 6 evidence-gathering; NOT + // fixed here -- the contract requires zero production lines and + // directs stop-and-report instead of a speculative fix). + // + // BuildSpawn resets the top-level MovementSequence / + // ServerControlSequence to 0 (InventoryWorldDropProjectionController + // .cs:200-201) but its Timestamps `with` override list only touches + // Position / Teleport / ForcePosition / Instance + // (InventoryWorldDropProjectionController.cs:182-188) -- it does NOT + // correspondingly reset Physics.Timestamps.Movement / + // .ServerControlledMove. Retail's per-object update_times channels + // are monotonic counters that do not reset when an item re-enters a + // container, so a split source that EVER received a Movement or + // ServerControlledMove wire update during an earlier stint with + // world presence (e.g. dropped once before, picked back up, split + // again) carries nonzero values in exactly those two Timestamps + // fields. The resulting spawn then fails + // RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent + // (:2321-2327), and split recovery THROWS instead of completing the + // canonical create-placement transaction -- which is exactly the + // property route 6's "enters the same canonical transaction" claim + // depends on holding unconditionally. See the final report for the + // recommended follow-up. + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint sourceGuid = 0x50000C40u; + WorldSession.EntitySpawn baseSource = ItemSpawn( + sourceGuid, position: null, containerId: DropHarness.PlayerGuid, stackSize: 6); + WorldSession.EntitySpawn source = baseSource with + { + MovementSequence = 2, + ServerControlSequence = 6, + Physics = baseSource.Physics!.Value with + { + Timestamps = baseSource.Physics.Value.Timestamps with + { + Movement = 2, + ServerControlledMove = 6, + }, + }, + }; + fixture.Controller.OnCreate(source); + Assert.True(drop.DispatchSplit(sourceGuid, stackSize: 6, splitAmount: 1)); + + const uint splitResultGuid = 0x80000D01u; + InvalidOperationException exception = Assert.Throws(() => + drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(splitResultGuid, x: 30f))); + Assert.Contains( + "inconsistent instance or parent projections", exception.Message); + } + + [Fact] + public void SplitToWorld_NewGuidPositionRecovery_EntersSameCanonicalTransactionAsDrop() + { + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint sourceGuid = 0x50000C01u; + fixture.Controller.OnCreate(ItemSpawn( + sourceGuid, position: null, containerId: DropHarness.PlayerGuid, stackSize: 10)); + + Assert.True(drop.DispatchSplit(sourceGuid, stackSize: 10, splitAmount: 3)); + const uint splitResultGuid = 0x80000901u; + Assert.False(fixture.Runtime.TryGetSnapshot(splitResultGuid, out _)); + + bool recovered = drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(splitResultGuid, x: 15f)); + + Assert.True(recovered); + Assert.True(fixture.Runtime.TryGetSnapshot( + splitResultGuid, out WorldSession.EntitySpawn spawn)); + Assert.Equal(3, spawn.StackSize); + Assert.Equal(0u, spawn.ContainerId); + Assert.True(fixture.Runtime.TryGetRecord(splitResultGuid, out LiveEntityRecord record)); + Assert.NotNull(record.WorldEntity); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + [Fact] + public void SecondSplitDrop_ResolvesIndependentlyWithoutClobberingFirst() + { + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint firstSource = 0x50000C10u; + const uint secondSource = 0x50000C20u; + fixture.Controller.OnCreate(ItemSpawn( + firstSource, position: null, containerId: DropHarness.PlayerGuid, stackSize: 5)); + fixture.Controller.OnCreate(ItemSpawn( + secondSource, position: null, containerId: DropHarness.PlayerGuid, stackSize: 8)); + + Assert.True(drop.DispatchSplit(firstSource, stackSize: 5, splitAmount: 2)); + const uint firstResult = 0x80000A01u; + Assert.True(drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(firstResult, x: 11f))); + // ACE confirms the first split by updating the remaining stack; that + // is also what clears ItemInteractionController's one-at-a-time + // pending-request gate so a second request can dispatch, exactly as + // retail's IsPlayerReadyToMakeInventoryRequest requires. + fixture.Objects.UpdateStackSize(firstSource, 3, 0); + + Assert.True(drop.DispatchSplit(secondSource, stackSize: 8, splitAmount: 4)); + const uint secondResult = 0x80000A02u; + Assert.True(drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(secondResult, x: 12f, positionSequence: 6))); + + Assert.True(fixture.Runtime.TryGetSnapshot( + firstResult, out WorldSession.EntitySpawn firstSpawn)); + Assert.True(fixture.Runtime.TryGetSnapshot( + secondResult, out WorldSession.EntitySpawn secondSpawn)); + Assert.Equal(2, firstSpawn.StackSize); + Assert.Equal(4, secondSpawn.StackSize); + Assert.Equal(11f, firstSpawn.Position!.Value.PositionX); + Assert.Equal(12f, secondSpawn.Position!.Value.PositionX); + Assert.Equal(2, fixture.Resources.RegisterCount); + } + + [Fact] + public void UnknownPositionWithNoPendingSplit_IsRejectedWithoutCreatingAnything() + { + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint unrelatedGuid = 0x80000B01u; + + bool recovered = drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(unrelatedGuid, x: 20f)); + + Assert.False(recovered); + Assert.False(fixture.Runtime.TryGetSnapshot(unrelatedGuid, out _)); + Assert.Equal(0, fixture.Resources.RegisterCount); + } + + [Fact] + public void NewerPositionAfterPendingConsumed_DoesNotReopenOrDoubleRegister() + { + using var fixture = new Fixture(originKnown: true); + var drop = new DropHarness(fixture); + const uint sourceGuid = 0x50000C30u; + fixture.Controller.OnCreate(ItemSpawn( + sourceGuid, position: null, containerId: DropHarness.PlayerGuid, stackSize: 6)); + Assert.True(drop.DispatchSplit(sourceGuid, stackSize: 6, splitAmount: 1)); + + const uint firstUnknown = 0x80000C01u; + const uint secondUnknown = 0x80000C02u; + Assert.True(drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(firstUnknown, x: 21f))); + + // A second, newer Position for a DIFFERENT unknown guid arrives after + // the one pending split identity is already consumed. Retail's + // static split identity (UIAttemptSplitTo3D @0x0058D850) is + // single-slot; there is nothing left to recognize this packet + // against, so it must fall through untouched rather than being + // mistaken for a second recovery. + bool secondRecovered = drop.Projection.TryRecoverUnknownPosition( + DropPositionUpdate(secondUnknown, x: 22f, positionSequence: 6)); + + Assert.False(secondRecovered); + Assert.False(fixture.Runtime.TryGetSnapshot(secondUnknown, out _)); + Assert.True(fixture.Runtime.TryGetSnapshot(firstUnknown, out _)); + Assert.Equal(1, fixture.Resources.RegisterCount); + } + + private static WorldSession.EntitySpawn ItemSpawn( + uint guid, + CreateObject.ServerPosition? position, + uint? containerId, + int? stackSize, + string name = "test item", + ushort instanceSequence = 1) + { + // Movement / ServerControlledMove timestamps are held at 0 (both here + // and at the top-level EntitySpawn projection below) to model the + // common baseline of an item that has never received a Movement or + // ServerControlledMove wire update while contained -- see + // SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering + // below for what happens (a discovered defect, not exercised by this + // helper) when a split source's retained timestamps are nonzero + // instead. + var timestamps = new PhysicsTimestamps( + 1, 0, 1, 1, 0, 0, 0, 1, instanceSequence); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000050u, + MotionTableId: 0x09000050u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: 0x02000050u, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: name, + ItemType: (uint)ItemType.Misc, + MotionState: null, + MotionTableId: 0x09000050u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + WeenieClassId: 273u, + StackSize: stackSize, + StackSizeMax: 1000, + ContainerId: containerId, + InstanceSequence: instanceSequence, + MovementSequence: 0, + ServerControlSequence: 0, + PositionSequence: 1, + Physics: physics); + } + + private static WorldSession.EntityPositionUpdate DropPositionUpdate( + uint guid, + float x, + ushort positionSequence = 5) => + new( + guid, + new CreateObject.ServerPosition(Cell, x, 12f, 6f, 1f, 0f, 0f, 0f), + Velocity: Vector3.Zero, + // Open-ground placement, not resting against another object's + // contact frame -- matches ItemSpawn's source AnimationFrame: + // null. BuildSpawn forwards this straight to the top-level + // PlacementId without touching Physics.AnimationFrame + // (InventoryWorldDropProjectionController.cs:206), so the two + // must already agree in the source for the recovered spawn to + // pass HasConsistentCreateIdentityAndParent's + // PlacementId == Physics.AnimationFrame check. + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: 0, + ForcePositionSequence: 0); + + /// + /// A minimal, real + + /// pair wired to a + /// shared , for exercising the actual drop/split + /// dispatch surface a player action drives (rather than reaching into + /// PendingSplitToWorldProjection directly, which the sibling + /// PendingSplitToWorldProjectionTests already covers at the unit level). + /// + private sealed class DropHarness + { + public const uint PlayerGuid = 0x50000001u; + public readonly ItemInteractionController Interaction; + public readonly InventoryWorldDropProjectionController Projection; + public readonly StackSplitQuantityState SplitQuantity = new(); + public uint SelectedObject; + + public DropHarness(Fixture fixture) + { + fixture.Objects.AddOrUpdate(new ClientObject + { + ObjectId = PlayerGuid, + Name = "Player", + Type = ItemType.Creature, + }); + var shared = new InventoryTransactionState(fixture.Objects); + var runtimeTransactions = new RuntimeInteractionTransactionState(shared); + Interaction = new ItemInteractionController( + fixture.Objects, + runtimeTransactions, + new InteractionState(), + playerGuid: () => PlayerGuid, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: _ => { }, + sendSplitToWorld: (_, _) => { }, + selectedObjectId: () => SelectedObject, + stackSplitQuantity: SplitQuantity, + playerOnGround: () => true); + Projection = new InventoryWorldDropProjectionController( + Interaction, + fixture.Objects, + fixture.Runtime, + fixture.Controller, + () => 100.0); + } + + public bool DispatchSplit(uint itemGuid, uint stackSize, uint splitAmount) + { + SelectedObject = itemGuid; + SplitQuantity.Reset(stackSize); + SplitQuantity.SetValue(splitAmount); + var payload = new ItemDragPayload( + itemGuid, + ItemDragSource.Inventory, + SourceSlot: 0, + SourceCell: new UiItemSlot()); + return Interaction.DropToWorld(payload); + } + } + private const uint Guid = 0x70000001u; private const uint Cell = 0x01010001u; diff --git a/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs b/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs index c0eed70b..b1e9021a 100644 --- a/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs +++ b/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs @@ -82,6 +82,52 @@ public sealed class PendingSplitToWorldProjectionTests Assert.False(pending.HasPending); } + [Fact] + public void Resolve_ClonesRelationshipAndAnimationStateWholesaleFromSource() + { + // R6-c (docs/research/2026-08-04-c4-route-6-contract.md): BuildSpawn's + // `source with { ... }` override list does not touch Physics.Children, + // Physics.Movement, Physics.AnimationFrame, or the top-level + // SetupTableId, so they clone byte-for-byte from the pre-split source + // rather than resetting. That was documented but never measured — + // assert it directly instead of reasoning about it. (Whether a live + // split source ever actually carries non-default values here is a + // separate, unmeasured question; nothing in the create-time pipeline + // reads PhysicsSpawnData.Children at all, and + // RuntimeRemoteBodyDescription.Construct only acts on Movement when + // RawData is non-empty — a resting inventory stack has none.) + var pending = new PendingSplitToWorldProjection(); + WorldSession.EntitySpawn baseSource = SourceSpawn(); + WorldSession.EntitySpawn source = baseSource with + { + SetupTableId = 0x0200ABCDu, + Physics = baseSource.Physics!.Value with + { + Children = new[] { new PhysicsAttachment(0x50000ABCu, 3u) }, + Movement = new PhysicsMovementData( + new byte[] { 1, 2, 3 }, + MotionState: null, + IsAutonomous: true), + AnimationFrame = 42u, + }, + }; + pending.Record(9u, source, amount: 2u, now: 1.0); + + Assert.True(pending.TryResolve( + PositionUpdate(0x80000777u), + now: 1.5, + out WorldSession.EntitySpawn spawn)); + + Assert.Equal(source.SetupTableId, spawn.SetupTableId); + PhysicsSpawnData physics = Assert.IsType(spawn.Physics); + Assert.True(physics.Children.HasValue); + Assert.Equal( + source.Physics!.Value.Children!.Value.ToArray(), + physics.Children!.Value.ToArray()); + Assert.Equal(source.Physics.Value.Movement, physics.Movement); + Assert.Equal(source.Physics.Value.AnimationFrame, physics.AnimationFrame); + } + [Fact] public void Resolve_SourceGuidDoesNotConsumePendingIdentity() {