acdream/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
Erik 529e0e9d88 feat(runtime): C3c - production placement cutover: both hosts on the residence conductors (routes 1+8)
Campaign P remaining-physics-divergence, placement cutover slice C3c
(docs/plans/2026-08-02-placement-cutover.md). Both production hosts now
register every initial Create through the residence + continuation-
executor + first-entry-conductor machinery (C0-C3b):

- Graphical (route 1): RegisterEntityWithInitialResidence at Create; the
  shared RuntimeFirstEntryDriveController pumps both conductors from the
  placement-receipt flow; MaterializeProjection and RebucketLiveEntity
  are presentation-only while a residence is ACTIVE (ExecutorCompleted is
  the presentation-binding receipt); post-residence entities take the
  full legacy path including the prepare_to_enter_world clock edges.
  PlayerModeController attaches presentation to the Runtime-published
  controller; its legacy resolve/step-heights/host-construction path is
  deleted; presentation-only rollback (retail has no entry-flow rollback).
- Headless (route 8): OnSpawned registers with residence when a drive
  exists; content-less sessions keep the pre-flip direct registration;
  SynchronizeLocalPlayer/CreateController/ApplySetupStepHeights deleted;
  prepared-collision read failure is a typed AwaitingCollisionSource
  retry; far remotes outside the service window complete celless.
- RuntimeLocalPlayerMovementState.Controller setter sealed internal; all
  controller mutation flows through the publication lifecycle.

Fix slices landed within this cutover, each dual-gated:
- F1: live movement-stat/server-physics application routed through the
  Runtime ownership seam (post-logout ingest crash on the retired
  controller eliminated; RuntimeMovementSkillProjection deleted).
- F2: login activation wedge - collision-admission prefix gate factored
  out of the seal (reentrant-commit RejectedAuthority), rearm generation
  identity corrected, PlayerModeAutoEntry requires the Runtime-published
  controller (world reveal can no longer seal unmaterialized).
- F3: landblock-prefix 0-sentinel replaced by explicit absent-id guards;
  map-corner landblocks (grid row/col 0) fully legal through admission,
  park/rearm/retire, quiescence, and outdoor shadow seeds.
- F5: local-player first-entry ground contact seeded by the shared
  SpawnPlacementSettler (moved App->Core) at FinalizeActivation - the
  retail first-gravity-frame touch (enter_world 0x00516170 carries no
  seed); the legacy unconditional force-seed is overwritten by a real
  floor-found contact; airborne spawns stay airborne; outbound contact
  bit verified end-to-end. Fixes the standing-cast 'You can't do that
  while in the air!' rejections.
- R1 (dual-review round): login constraint leash armed at the committed
  placement (HandleReceivedPosition 0x00453FD0 analog); register rows
  AD-61 (settle-timing compression now covering the local player) and
  AD-42 (repointed off the deleted resolve split) in this commit;
  residence-conversion owner API; wire-landblock guards; drive-pending
  ledger in IsConverged; route attach/detach latch; executor-drain drift
  model documented + source-pinned.

Gates: Runtime 1,003, App 4,039/3 skips, Headless 79, complete solution
10,816/0 failed/4 skips (Release, -m:1); connected lifecycle/reconnect
gate PASS (logs/connected-world-gate-20260802-175401; graceful exits,
world-visible, zero airborne rejections). The nine-stop soak remains red
for the pre-existing 6b28ff99 whole-world collision-clone throughput
regression (attributed with evidence; scheduled as its own slice before
C5). Dual Opus reviews (retail-conformance + adversarial): delta PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:10:33 +02:00

378 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"),
RemoteTeleport = Stage("remote teleport"),
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",
"remote teleport",
"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);
}
}