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

@ -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<InvalidOperationException>(() =>
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);
/// <summary>
/// A minimal, real <see cref="ItemInteractionController"/> +
/// <see cref="InventoryWorldDropProjectionController"/> pair wired to a
/// shared <see cref="Fixture"/>, for exercising the actual drop/split
/// dispatch surface a player action drives (rather than reaching into
/// <c>PendingSplitToWorldProjection</c> directly, which the sibling
/// PendingSplitToWorldProjectionTests already covers at the unit level).
/// </summary>
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;

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