Flips the last remote classification (SetPosition: teleport-advanced and
cell-less) onto 4b-1's RuntimeRemotePlacementDriveController, runs retail's
teleport_hook before the placement, and deletes the legacy remote-teleport
machinery. Contract: docs/research/2026-08-04-c4-route-4b-3-contract.md.
Retail: MoveOrTeleport @0x00516330's branch @0x00516386 -> teleport_hook
@0x005163EF -> SetFlags(0x1012) @0x00516414 -> SetPosition @0x00516420 ->
return 1 @0x00516438. The hook @0x00514ED0 runs BEFORE the placement and
regardless of its outcome. Retail places this branch unconditionally, at any
distance and any contact state (arg4 is read only @0x0051638E, after the
branch) — which is what retires AP-137's cell-less enqueue-vs-place delta.
D1 — the classifier's cell-less input is now the PRE-merge committed cell.
Retail's predicate is `this_1->cell == 0`, the BODY's own cell at
MoveOrTeleport entry (this_1 is assigned from this @0x00516334). acdream fed
the POST-merge canonical.FullCellId, which RefreshSnapshot ->
RefreshDerivedState -> SetFullCell has already stamped with the accepted wire
cell; a zero wire cell fails validation into RejectedData first. The shipped
remote cell-less predicate was therefore dead code, not merely different from
remotePlacementRequired. Threaded via a builder overload; route 1's overload
is untouched. The graphical !IsSpatiallyVisible arm of
projectionRequiresTeleportHook is deleted — a presentation predicate with no
retail analogue that fired the teleport machinery on a routine hot path.
Deleted: RemoteTeleportController (605), RemoteTeleportPlacement (85),
RemoteShadowPlacementSynchronizer (49), their 1,709 lines of tests, the
remotePlacementRequired predicate, the TeleportHookRequired plumbing, the
legacy pre-operation ConstrainTo fallback, and the player arm's legacy
!IsGrounded fallback. Net -2,030 lines.
Structural fix (two independent Opus reviews, round 1 FAIL/FAIL): three of the
four MAJORs were one defect — OnPosition carried two parallel inline copies of
the routing tail (player-guid, NPC-guid) that had drifted. Extracted
RunRemoteArmTail (3 call sites) and ApplyWireAirborneLeftoverBookkeeping (2),
both branches now share one implementation.
A1 ToConstraintArm mapped AirborneSnap -> AirborneNoOperation, so the NPC
arm armed ConstrainTo ZERO times for an out-of-contact wire-grounded
creature — a regression this slice introduced while closing a
structurally identical hole. Now maps to NearInterpolate; switch made
total with a throwing default proven unreachable.
R1 D2's write-nothing shape existed on the player arm only; NPC packets
fell through and wrote the body. Retail makes no player/NPC distinction.
R2 report_collision_end(this,1) @0x00514F31 was bound to
ShadowObjects.Suspend, a port of a DIFFERENT retail function
(remove_shadows_from_cells) that teleport_hook never calls. Now routes
to RuntimeCollisionReportingState.LeaveWorld, which wraps the private
ForceEnd in an admission-blocking transaction so a DoCollisionEnd
callback cannot recreate the contact table.
R3/A2 A teleported NPC synthesized ServerVelocity from the teleport distance
(~1,000+ m/s) and planned a run cycle from it. Both the install and
RemoteServerControlledVelocityCycle.Apply now gate on !isTeleportRoute.
BISECT HAZARD — A1's fix is correct only BECAUSE R1 landed. AirborneSnap is
reachable wire-airborne on the NPC arm only while D2's shape is missing there.
Reverting R1 alone silently inverts A1 into the opposite divergence: arming
where retail returns 0. Revert both or neither.
Also in the velocity hunk: the NPC block's two !IsPlayerGuid(update.Guid)
guards were dropped when it was wrapped in `if (!isTeleportRoute)`. Safe — all
five exit paths of the enclosing IsPlayerGuid block return, so the predicate is
unconditionally false below it — but it was unremarked by both reviews.
Register: AP-137 REWRITTEN (not deleted) to the surviving acdream-only
divergences — null classification during the login window and Rejected*
through UnroutedCatchUp keep a row. AD-42's RemoteTeleportController citation
retired; AP-136/AP-138 writer lists corrected to the two surviving non-Position
rebucket writers; AP-138 gains the teleport arm as a second producer of the
visible-without-collision residual (retirement path remains #309). AP-135 is
untouched and its two airborne bookkeeping writes are preserved on both arms.
AP-131 does not retire; #276 does not close.
Proof obligation 1: ParkCollisionResidents' overlap throw stays unreachable —
the teleport arm adds packets to the same TryBeginExclusiveAuthoredPlacement
one-operation-per-key machinery the far arm uses, opens no new operation shape,
and every DeferredCell outcome cancels synchronously with
restoreCancelledPark: true. The guarded property remains
HasOldPrefixPlacementDebt's stall, not a throw (4b-1's B2 caveat stands).
Correction to an earlier claim: LiveEntityPresentationController's
_activePlacementOwners was NOT write-never at HEAD —
remotePlacementRequired -> BeginPlacement -> Begin -> BeginAuthoritativePlacement
was a live writer chain. It becomes write-never BECAUSE this slice deletes that
chain, which is why deleting the dead half is behaviour-preserving.
Probe: ACDREAM_PROBE_REMOTE_TELEPORT=1 emits one [remote-teleport] line per
routed arm (guid, cause, hook-ran, placement status). TEMPORARY, strip with the
probe family.
Carried, disclosed not fixed: no dedicated bidirectional collision-partner test
for R2 (the wiring, not LeaveWorld itself, is what lacks coverage); the
stress test's teleport step drives hand-written field assignments rather than
the canonical arm; the per-packet runTeleportHook closure allocation (network
path, not the resolve path Slice I's 0 B discipline governs — file before
route 5 adds a fourth call site). B2: IRuntimeCollisionReportObserver has zero
production implementations, so retail's bidirectional DoCollisionEnd half still
reaches no gameplay consumer — this fix closes the wrong-function binding, not
that nobody listens.
Complete Release suite MEASURED at 11,013 passed / 4 skipped / 0 failed
(baseline 11,027/4/0; net -14 = ~33 deleted test cases against ~19 added).
Neither known flake fired (#302 PortalProjectionTests GC-allocation, #308
NakEmissionTests wall-clock).
STILL OWED: the two-client connected gate, which MUST use an NPC/creature
teleport target. Both round-1 MAJORs lived on the NPC arm and the velocity
cycle early-returns for 0x50xxxxxx guids, so a player target structurally
cannot observe A1, A2, or R3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
13 KiB
C#
376 lines
13 KiB
C#
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Player;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Tests.Net;
|
|
|
|
public sealed class LiveSessionResetPlanTests
|
|
{
|
|
private sealed class FailingOnceResources : ILiveEntityResourceLifecycle
|
|
{
|
|
private bool _failurePending = true;
|
|
|
|
public void Register(WorldEntity entity) { }
|
|
|
|
public void Unregister(WorldEntity entity)
|
|
{
|
|
if (_failurePending)
|
|
{
|
|
_failurePending = false;
|
|
throw new InvalidOperationException("transient teardown");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Execute_EmptyPlanConverges()
|
|
{
|
|
var plan = new LiveSessionResetPlan([]);
|
|
|
|
plan.Execute();
|
|
plan.Execute();
|
|
|
|
Assert.Empty(plan.StageNames);
|
|
}
|
|
|
|
[Fact]
|
|
public void Execute_AttemptsEveryStageAndAggregatesNamedFailures()
|
|
{
|
|
var calls = new List<string>();
|
|
var plan = new LiveSessionResetPlan(
|
|
[
|
|
new("first", () =>
|
|
{
|
|
calls.Add("first");
|
|
throw new InvalidOperationException("one");
|
|
}),
|
|
new("second", () => calls.Add("second")),
|
|
new("third", () =>
|
|
{
|
|
calls.Add("third");
|
|
throw new ArgumentException("three");
|
|
}),
|
|
]);
|
|
|
|
AggregateException error = Assert.Throws<AggregateException>(plan.Execute);
|
|
|
|
Assert.Equal(["first", "second", "third"], calls);
|
|
Assert.Collection(
|
|
error.InnerExceptions,
|
|
first => Assert.Equal(
|
|
"first",
|
|
Assert.IsType<LiveSessionResetStageException>(first).StageName),
|
|
third => Assert.Equal(
|
|
"third",
|
|
Assert.IsType<LiveSessionResetStageException>(third).StageName));
|
|
}
|
|
|
|
[Fact]
|
|
public void Execute_AfterFailedAttemptCanConvergeWithoutSkippingStages()
|
|
{
|
|
int firstCalls = 0;
|
|
int secondCalls = 0;
|
|
var plan = new LiveSessionResetPlan(
|
|
[
|
|
new("transient", () =>
|
|
{
|
|
firstCalls++;
|
|
if (firstCalls == 1)
|
|
throw new InvalidOperationException("transient");
|
|
}),
|
|
new("always", () => secondCalls++),
|
|
]);
|
|
|
|
Assert.Throws<AggregateException>(plan.Execute);
|
|
plan.Execute();
|
|
|
|
Assert.Equal(2, firstCalls);
|
|
Assert.Equal(2, secondCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void Execute_BlocksNextSessionUntilOriginRetirementConverges()
|
|
{
|
|
const uint sourceId = 0x5353FFFFu;
|
|
var state = new GpuWorldState();
|
|
state.AddLandblock(new LoadedLandblock(
|
|
sourceId,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
var origin = new LiveWorldOriginState();
|
|
Assert.True(origin.TryInitialize(0x53, 0x53));
|
|
bool failRetirement = true;
|
|
var controller = new StreamingController(
|
|
enqueueLoad: static (_, _) => { },
|
|
enqueueUnload: static _ => { },
|
|
drainCompletions: static _ => Array.Empty<LandblockStreamResult>(),
|
|
applyTerrain: static (_, _) => { },
|
|
state,
|
|
nearRadius: 0,
|
|
farRadius: 0,
|
|
removeTerrain: _ =>
|
|
{
|
|
if (failRetirement)
|
|
throw new InvalidOperationException("injected ending-session failure");
|
|
});
|
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
|
int identityResets = 0;
|
|
var plan = new LiveSessionResetPlan(
|
|
[
|
|
new("teleport transit", () =>
|
|
{
|
|
if (!recenter.Reset(sessionEnding: true))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"streaming-origin retirement remains pending");
|
|
}
|
|
}),
|
|
new("session identity", () =>
|
|
{
|
|
identityResets++;
|
|
origin.Reset();
|
|
}),
|
|
]);
|
|
|
|
AggregateException blocked = Assert.Throws<AggregateException>(plan.Execute);
|
|
LiveSessionResetStageException failure = Assert.IsType<LiveSessionResetStageException>(
|
|
Assert.Single(blocked.InnerExceptions));
|
|
Assert.Equal("teleport transit", failure.StageName);
|
|
Assert.Equal(1, identityResets);
|
|
|
|
failRetirement = false;
|
|
for (int frame = 0;
|
|
frame < 64
|
|
&& !controller.IsOriginRecenterRetirementComplete();
|
|
frame++)
|
|
{
|
|
controller.Tick(0x53, 0x53);
|
|
}
|
|
plan.Execute();
|
|
|
|
Assert.Equal(2, identityResets);
|
|
Assert.False(recenter.IsPending);
|
|
Assert.False(origin.IsKnown);
|
|
Assert.True(origin.TryInitialize(0x61, 0x62));
|
|
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
|
|
}
|
|
|
|
[Fact]
|
|
public void Execute_ReentrantAttemptIsReportedButLaterStagesStillRun()
|
|
{
|
|
LiveSessionResetPlan? plan = null;
|
|
bool tailRan = false;
|
|
plan = new LiveSessionResetPlan(
|
|
[
|
|
new("reentrant", () => plan!.Execute()),
|
|
new("tail", () => tailRan = true),
|
|
]);
|
|
|
|
AggregateException error = Assert.Throws<AggregateException>(plan.Execute);
|
|
|
|
Assert.True(tailRan);
|
|
LiveSessionResetStageException stage = Assert.IsType<LiveSessionResetStageException>(
|
|
Assert.Single(error.InnerExceptions));
|
|
Assert.Equal("reentrant", stage.StageName);
|
|
Assert.IsType<InvalidOperationException>(stage.InnerException);
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_RejectsDuplicateStageNames()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new LiveSessionResetPlan(
|
|
[
|
|
new("duplicate", () => { }),
|
|
new("duplicate", () => { }),
|
|
]));
|
|
}
|
|
|
|
[Fact]
|
|
public void Manifest_PreservesHostOrderAndForwardsExactRetiringGeneration()
|
|
{
|
|
var calls = new List<string>();
|
|
RuntimeGenerationToken observed = default;
|
|
Action Stage(string name) => () => calls.Add(name);
|
|
var plan = LiveSessionResetManifest.Create(new()
|
|
{
|
|
MouseCapture = Stage("mouse capture"),
|
|
PlayerPresentation = Stage("player presentation"),
|
|
TeleportPresentation = Stage("teleport presentation"),
|
|
SessionDialogs = Stage("session dialogs"),
|
|
SettingsCharacterContext = Stage("settings character context"),
|
|
EquippedChildren = Stage("equipped children"),
|
|
InteractionPresentation = Stage("interaction presentation"),
|
|
SelectionPresentation = Stage("selection presentation"),
|
|
ParticleVisibility = Stage("particle visibility"),
|
|
InboundEventFifo = Stage("inbound event fifo"),
|
|
LiveLiveness = Stage("live liveness"),
|
|
RuntimeGeneration = generation =>
|
|
{
|
|
observed = generation;
|
|
calls.Add("runtime generation");
|
|
},
|
|
SessionIdentityPresentation = _ =>
|
|
calls.Add("session identity presentation"),
|
|
NetworkEffects = Stage("network effects"),
|
|
AnimationHookFrames = Stage("animation hook frames"),
|
|
LivePresentation = Stage("live presentation"),
|
|
RemoteMovementDiagnostics =
|
|
Stage("remote movement diagnostics"),
|
|
});
|
|
|
|
var retiring = new RuntimeGenerationToken(73);
|
|
plan.Execute(retiring);
|
|
|
|
Assert.Equal(ExpectedManifestNames(), plan.StageNames);
|
|
Assert.Equal(ExpectedManifestNames(), calls);
|
|
Assert.Equal(retiring, observed);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphicalResetHost_RetriesExactProjectionBeforeIdentityClears()
|
|
{
|
|
const uint player = 0x50000001u;
|
|
const uint landblock = 0x0101FFFFu;
|
|
using GameRuntime runtime = GameRuntimeTestFactory.Create();
|
|
runtime.PlayerIdentity.ServerGuid = player;
|
|
var spatial = new GpuWorldState();
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
landblock,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
var live = new LiveEntityRuntime(
|
|
spatial,
|
|
new FailingOnceResources(),
|
|
runtime.EntityObjects);
|
|
// C3c: this GameRuntime has no session, so its generation is zero and
|
|
// residence-based registration (RegisterLiveEntity) correctly refuses
|
|
// an initial world Create. The subject here is reset/teardown retry,
|
|
// not the create flow — seed the entity through the legacy direct
|
|
// Runtime registration, which needs no residence lease.
|
|
runtime.EntityObjects.RegisterEntity(Spawn(player, 1, 1, 0x01010001u));
|
|
live.MaterializeLiveEntity(
|
|
player,
|
|
0x01010001u,
|
|
id => Entity(id, player));
|
|
var host = new GraphicalRuntimeGenerationResetHost(
|
|
live,
|
|
static () => { });
|
|
var retiring = new RuntimeGenerationToken(3);
|
|
|
|
RuntimeGenerationResetStageException first =
|
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
|
() => runtime.ResetGeneration(retiring, host));
|
|
|
|
Assert.Equal(
|
|
RuntimeGenerationResetStage.RetireEntities,
|
|
first.Stage);
|
|
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
|
|
Assert.Equal(1, live.PendingTeardownCount);
|
|
Assert.Equal(1, live.MaterializedCount);
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
|
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
|
Assert.Equal(0, live.Count);
|
|
Assert.Equal(0, live.PendingTeardownCount);
|
|
Assert.Equal(0, live.MaterializedCount);
|
|
}
|
|
|
|
private static string[] ExpectedManifestNames() =>
|
|
[
|
|
"mouse capture",
|
|
"player presentation",
|
|
"teleport presentation",
|
|
"session dialogs",
|
|
"settings character context",
|
|
"equipped children",
|
|
"interaction presentation",
|
|
"selection presentation",
|
|
"particle visibility",
|
|
"inbound event fifo",
|
|
"live liveness",
|
|
"runtime generation",
|
|
"session identity presentation",
|
|
"network effects",
|
|
"animation hook frames",
|
|
"live presentation",
|
|
"remote movement diagnostics",
|
|
];
|
|
|
|
private static WorldEntity Entity(uint id, uint guid) => new()
|
|
{
|
|
Id = id,
|
|
ServerGuid = guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = System.Numerics.Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
};
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
ushort positionSequence,
|
|
uint cell)
|
|
{
|
|
const PhysicsStateFlags state = PhysicsStateFlags.ReportCollisions;
|
|
var position = new CreateObject.ServerPosition(
|
|
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)state,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
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,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)state,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: positionSequence,
|
|
Physics: physics);
|
|
}
|
|
}
|