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 at6dc7ba51; +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>
232 lines
8.4 KiB
C#
232 lines
8.4 KiB
C#
using System.Numerics;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
public sealed class PendingSplitToWorldProjectionTests
|
|
{
|
|
[Fact]
|
|
public void Resolve_ClonesSourceAppearanceIntoAuthoritativeGroundPlacement()
|
|
{
|
|
var pending = new PendingSplitToWorldProjection();
|
|
WorldSession.EntitySpawn source = SourceSpawn();
|
|
pending.Record(7u, source, amount: 3u, now: 12.0);
|
|
WorldSession.EntityPositionUpdate update = PositionUpdate(
|
|
guid: 0x80000435u,
|
|
instance: 4,
|
|
position: 9,
|
|
teleport: 2,
|
|
forcePosition: 3);
|
|
|
|
Assert.True(pending.TryResolve(update, now: 12.25, out var spawn));
|
|
|
|
Assert.Equal(update.Guid, spawn.Guid);
|
|
Assert.Equal(source.SetupTableId, spawn.SetupTableId);
|
|
Assert.Equal(source.AnimPartChanges, spawn.AnimPartChanges);
|
|
Assert.Equal(source.TextureChanges, spawn.TextureChanges);
|
|
Assert.Equal(source.SubPalettes, spawn.SubPalettes);
|
|
Assert.Equal(source.BasePaletteId, spawn.BasePaletteId);
|
|
Assert.Equal(source.WeenieClassId, spawn.WeenieClassId);
|
|
Assert.Equal(3, spawn.StackSize);
|
|
Assert.Equal(0u, spawn.ContainerId);
|
|
Assert.Equal(0u, spawn.WielderId);
|
|
Assert.Equal(0u, spawn.CurrentWieldedLocation);
|
|
Assert.Null(spawn.ParentGuid);
|
|
Assert.Null(spawn.ParentLocation);
|
|
Assert.Equal(update.Position, spawn.Position);
|
|
Assert.Equal(update.PlacementId, spawn.PlacementId);
|
|
Assert.Equal(update.InstanceSequence, spawn.InstanceSequence);
|
|
Assert.Equal(update.PositionSequence, spawn.PositionSequence);
|
|
|
|
PhysicsSpawnData physics = Assert.IsType<PhysicsSpawnData>(spawn.Physics);
|
|
Assert.Equal(update.Position, physics.Position);
|
|
Assert.Null(physics.Parent);
|
|
Assert.Equal(update.Velocity, physics.Velocity);
|
|
Assert.Equal(update.InstanceSequence, physics.Timestamps.Instance);
|
|
Assert.Equal(update.PositionSequence, physics.Timestamps.Position);
|
|
Assert.Equal(update.TeleportSequence, physics.Timestamps.Teleport);
|
|
Assert.Equal(
|
|
update.ForcePositionSequence,
|
|
physics.Timestamps.ForcePosition);
|
|
Assert.False(pending.HasPending);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_ConsumesOneRetailPendingIdentity()
|
|
{
|
|
var pending = new PendingSplitToWorldProjection();
|
|
pending.Record(1u, SourceSpawn(), amount: 1u, now: 5.0);
|
|
|
|
Assert.True(pending.TryResolve(
|
|
PositionUpdate(0x80000435u),
|
|
now: 5.1,
|
|
out _));
|
|
Assert.False(pending.TryResolve(
|
|
PositionUpdate(0x80000436u),
|
|
now: 5.2,
|
|
out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_RejectsAtRetailTenSecondBoundary()
|
|
{
|
|
var pending = new PendingSplitToWorldProjection();
|
|
pending.Record(1u, SourceSpawn(), amount: 1u, now: 5.0);
|
|
|
|
Assert.False(pending.TryResolve(
|
|
PositionUpdate(0x80000435u),
|
|
now: 15.0,
|
|
out _));
|
|
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()
|
|
{
|
|
var pending = new PendingSplitToWorldProjection();
|
|
WorldSession.EntitySpawn source = SourceSpawn();
|
|
pending.Record(1u, source, amount: 1u, now: 5.0);
|
|
|
|
Assert.False(pending.TryResolve(
|
|
PositionUpdate(source.Guid),
|
|
now: 5.1,
|
|
out _));
|
|
Assert.True(pending.HasPending);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn SourceSpawn()
|
|
{
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 2,
|
|
State: 3,
|
|
Vector: 4,
|
|
Teleport: 5,
|
|
ServerControlledMove: 6,
|
|
ForcePosition: 7,
|
|
ObjDesc: 8,
|
|
Instance: 9);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: 0x00000408u,
|
|
Position: null,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x0200030Bu,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: new PhysicsAttachment(0x50000001u, 0u),
|
|
Children: null,
|
|
Scale: 1f,
|
|
Friction: 0.5f,
|
|
Elasticity: 0.05f,
|
|
Translucency: null,
|
|
Velocity: Vector3.Zero,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
|
|
return new WorldSession.EntitySpawn(
|
|
Guid: 0x50000010u,
|
|
Position: null,
|
|
SetupTableId: 0x0200030Bu,
|
|
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
|
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
|
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: 0x04000001u,
|
|
ObjScale: 1f,
|
|
Name: "Copper Pea",
|
|
ItemType: 0x00000001u,
|
|
MotionState: null,
|
|
MotionTableId: 0x09000001u,
|
|
WeenieClassId: 273u,
|
|
StackSize: 100,
|
|
StackSizeMax: 1000,
|
|
ContainerId: 0x50000001u,
|
|
WielderId: 0u,
|
|
CurrentWieldedLocation: 0u,
|
|
InstanceSequence: 9,
|
|
PositionSequence: 1,
|
|
ParentGuid: 0x50000001u,
|
|
ParentLocation: 0u,
|
|
Physics: physics);
|
|
}
|
|
|
|
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
|
uint guid,
|
|
ushort instance = 4,
|
|
ushort position = 9,
|
|
ushort teleport = 2,
|
|
ushort forcePosition = 3)
|
|
{
|
|
var serverPosition = new CreateObject.ServerPosition(
|
|
0xA9B4001Au,
|
|
12f,
|
|
34f,
|
|
1.5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
return new WorldSession.EntityPositionUpdate(
|
|
guid,
|
|
serverPosition,
|
|
Velocity: Vector3.Zero,
|
|
PlacementId: 0x65u,
|
|
IsGrounded: true,
|
|
InstanceSequence: instance,
|
|
PositionSequence: position,
|
|
TeleportSequence: teleport,
|
|
ForcePositionSequence: forcePosition);
|
|
}
|
|
}
|