test(physics): C4 route 6 — drops/split-recovery closure, zero production lines

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<bool> 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 16:45:57 +02:00
parent 21cd6e9b2b
commit 1b484937b6
5 changed files with 694 additions and 4 deletions

View file

@ -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<PhysicsSpawnData>(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()
{