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>
This commit is contained in:
parent
78f1eb1896
commit
529e0e9d88
68 changed files with 5977 additions and 831 deletions
59
tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs
Normal file
59
tests/AcDream.App.Tests/Input/C3cF2AutoEntryWiringTests.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
namespace AcDream.App.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F2 (2026-08-02): source pin for the production player-mode auto-entry
|
||||
/// precondition. <c>PlayerModeAutoEntry</c> is a ONE-SHOT that disarms before
|
||||
/// invoking its callback, and the production callback completes the world
|
||||
/// reveal, so an attempt made before the Runtime first-entry conductor has
|
||||
/// committed permanently seals the reveal with the player never in world —
|
||||
/// the second link of the connected-gate login wedge
|
||||
/// (logs/connected-world-gate-20260802-130455: one "not committed yet" line,
|
||||
/// then event=complete with materialized=False and 5,577
|
||||
/// readiness-after-terminal rejections). The production context therefore has
|
||||
/// to report the same commit <c>PlayerModeController.TryEnter</c> requires.
|
||||
/// The guard's own one-shot/latch behavior is covered behaviorally by
|
||||
/// AcDream.Core.Tests.Input.AutoEnterPlayerModeTests; only the production
|
||||
/// context's dependency graph (a ~15-dependency PlayerModeController) has no
|
||||
/// focused harness, hence the source pin.
|
||||
/// </summary>
|
||||
public sealed class C3cF2AutoEntryWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProductionAutoEntryRequiresTheRuntimePublishedController()
|
||||
{
|
||||
string source = ReadSource("Input", "PlayerModeAutoEntry.cs");
|
||||
|
||||
Assert.DoesNotContain(
|
||||
"public bool IsPlayerControllerReady => true;",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"IsRuntimePublished: true",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"record.PhysicsHost is EntityPhysicsHost",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadSource(params string[] relativePath)
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return File.ReadAllText(Path.Combine(
|
||||
directory.FullName,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
Path.Combine(relativePath)));
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,24 +12,200 @@ namespace AcDream.App.Tests;
|
|||
/// </summary>
|
||||
internal static class LiveEntityRuntimeFixture
|
||||
{
|
||||
/// <summary>
|
||||
/// C3c: initial-Create registration now begins the canonical residence
|
||||
/// lease, whose admission requires a live session generation. Focused
|
||||
/// App fixtures bind a fixed non-zero token exactly like the Runtime
|
||||
/// conductor fixtures do.
|
||||
/// </summary>
|
||||
private static RuntimeEntityObjectLifetime WithGeneration(
|
||||
RuntimeEntityObjectLifetime lifetime)
|
||||
{
|
||||
lifetime.BindEventContext(
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
return lifetime;
|
||||
}
|
||||
|
||||
public static LiveEntityRuntime Create(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId);
|
||||
var lifetime = WithGeneration(
|
||||
new RuntimeEntityObjectLifetime(firstLocalEntityId));
|
||||
return new LiveEntityRuntime(spatial, resources, lifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c: a runtime whose initial-create residences can actually be driven
|
||||
/// to completion — collision generation committed for
|
||||
/// <paramref name="landblockId"/>, the production
|
||||
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/>
|
||||
/// constructed against the lifetime, and an acknowledge-only placement
|
||||
/// subscription mirroring the host rules (Discard/ExecutorCompleted
|
||||
/// acknowledged; Place/Withdraw left at the head for the conductors).
|
||||
/// Tests exercising post-residence legacy update paths register, then
|
||||
/// call <see cref="DrivenLiveEntityRuntime.Pump"/> to complete the
|
||||
/// conductors exactly like the composed host's Create-transaction pump.
|
||||
/// </summary>
|
||||
internal sealed class DrivenLiveEntityRuntime
|
||||
{
|
||||
internal required LiveEntityRuntime Runtime { get; init; }
|
||||
internal required RuntimeEntityObjectLifetime Lifetime { get; init; }
|
||||
internal required AcDream.Runtime.Session.RuntimeFirstEntryDriveController
|
||||
FirstEntry { get; init; }
|
||||
internal required AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription Subscription { get; init; }
|
||||
|
||||
internal void Pump() => FirstEntry.DriveAll();
|
||||
}
|
||||
|
||||
public static DrivenLiveEntityRuntime CreateDriven(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
uint landblockId = 0x01010000u)
|
||||
{
|
||||
var lifetime = WithGeneration(new RuntimeEntityObjectLifetime());
|
||||
lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
landblockId & 0xFFFF0000u, 1UL);
|
||||
lifetime.Physics.Engine.AddLandblock(
|
||||
landblockId & 0xFFFF0000u,
|
||||
new AcDream.Core.Physics.TerrainSurface(
|
||||
new byte[81], new float[256]),
|
||||
Array.Empty<AcDream.Core.Physics.CellSurface>(),
|
||||
Array.Empty<AcDream.Core.Physics.PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId & 0xFFFF0000u, 1UL, ready: true);
|
||||
var runtime = new LiveEntityRuntime(spatial, resources, lifetime);
|
||||
var movement = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerMovementState();
|
||||
var identity = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerIdentityState();
|
||||
var publication = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsPublicationState(
|
||||
lifetime.Entities,
|
||||
lifetime.Physics,
|
||||
movement,
|
||||
identity);
|
||||
movement.AttachPhysicsPublication(publication);
|
||||
lifetime.LocalPlayerFirstEntry.BindPublication(publication);
|
||||
var firstEntry = new AcDream.Runtime.Session
|
||||
.RuntimeFirstEntryDriveController(
|
||||
lifetime,
|
||||
new AcDream.Runtime.GameRuntimeClock(),
|
||||
new SphereCollisionSource(),
|
||||
static () => AcDream.Runtime.Gameplay
|
||||
.PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f,
|
||||
1.835f,
|
||||
AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerShadowDisposition
|
||||
.ProvenShapeless));
|
||||
var subscription = new AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription(
|
||||
lifetime.Placements,
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
new AckOnlyPlacementSink(runtime));
|
||||
return new DrivenLiveEntityRuntime
|
||||
{
|
||||
Runtime = runtime,
|
||||
Lifetime = lifetime,
|
||||
FirstEntry = firstEntry,
|
||||
Subscription = subscription,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AckOnlyPlacementSink(LiveEntityRuntime runtime)
|
||||
: AcDream.Runtime.Physics.IRuntimePlacementProjectionSink
|
||||
{
|
||||
public bool TryApply(
|
||||
in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot
|
||||
projection)
|
||||
{
|
||||
if (projection.Kind is AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionKind.Discard)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (projection.Kind is AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
return projection.Token.ExactCellId == 0u
|
||||
|| runtime.TryApplyInitialCreateCompletionPresentation(
|
||||
in projection);
|
||||
}
|
||||
return !runtime.HasActiveInitialCreateResidence(
|
||||
projection.Token.Entity)
|
||||
&& runtime.TryApplyRuntimePlacementProjection(in projection);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SphereCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision>.Loaded(
|
||||
new AcDream.Core.Physics.FlatSetupCollision(
|
||||
System.Collections.Immutable.ImmutableArray<
|
||||
AcDream.Core.Physics.FlatCollisionCylinder>.Empty,
|
||||
[new AcDream.Core.Physics.FlatCollisionSphere(
|
||||
System.Numerics.Vector3.Zero, 0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f));
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public static LiveEntityRuntime Create(
|
||||
GpuWorldState spatial,
|
||||
ILiveEntityResourceLifecycle resources,
|
||||
PhysicsEngine physicsEngine,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime(
|
||||
var lifetime = WithGeneration(new RuntimeEntityObjectLifetime(
|
||||
physicsEngine,
|
||||
firstLocalEntityId);
|
||||
firstLocalEntityId));
|
||||
return new LiveEntityRuntime(spatial, resources, lifetime);
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +215,8 @@ internal static class LiveEntityRuntimeFixture
|
|||
Action<LiveEntityRecord> tearDownRuntimeComponents,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId);
|
||||
var lifetime = WithGeneration(
|
||||
new RuntimeEntityObjectLifetime(firstLocalEntityId));
|
||||
return new LiveEntityRuntime(
|
||||
spatial,
|
||||
resources,
|
||||
|
|
@ -53,7 +230,8 @@ internal static class LiveEntityRuntimeFixture
|
|||
ILiveEntityRuntimeComponentLifecycle runtimeComponentLifecycle,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime(firstLocalEntityId);
|
||||
var lifetime = WithGeneration(
|
||||
new RuntimeEntityObjectLifetime(firstLocalEntityId));
|
||||
return new LiveEntityRuntime(
|
||||
spatial,
|
||||
resources,
|
||||
|
|
@ -68,9 +246,9 @@ internal static class LiveEntityRuntimeFixture
|
|||
PhysicsEngine physicsEngine,
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
|
||||
{
|
||||
var lifetime = new RuntimeEntityObjectLifetime(
|
||||
var lifetime = WithGeneration(new RuntimeEntityObjectLifetime(
|
||||
physicsEngine,
|
||||
firstLocalEntityId);
|
||||
firstLocalEntityId));
|
||||
return new LiveEntityRuntime(
|
||||
spatial,
|
||||
resources,
|
||||
|
|
|
|||
53
tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs
Normal file
53
tests/AcDream.App.Tests/LiveEntitySpawnFixture.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// C3c: initial-Create registration now freezes the raw create for the
|
||||
/// canonical residence lease and requires the flattened parser projections
|
||||
/// to agree with the nested PhysicsDesc block
|
||||
/// (RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent).
|
||||
/// Legacy hand-built fixture spawns predate that gate; this helper derives
|
||||
/// the minimal consistent nested block from the flattened fields.
|
||||
/// </summary>
|
||||
internal static class LiveEntitySpawnFixture
|
||||
{
|
||||
internal static WorldSession.EntitySpawn WithConsistentPhysics(
|
||||
this WorldSession.EntitySpawn spawn) => spawn with
|
||||
{
|
||||
Physics = new PhysicsSpawnData(
|
||||
RawState: spawn.PhysicsState ?? 0u,
|
||||
Position: spawn.Position,
|
||||
Movement: null,
|
||||
AnimationFrame: spawn.PlacementId,
|
||||
SetupTableId: spawn.SetupTableId,
|
||||
MotionTableId: spawn.MotionTableId,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: spawn.ParentGuid is { } parentGuid
|
||||
&& spawn.ParentLocation is { } parentLocation
|
||||
? new PhysicsAttachment(parentGuid, parentLocation)
|
||||
: null,
|
||||
Children: null,
|
||||
Scale: spawn.ObjScale,
|
||||
Friction: spawn.Friction,
|
||||
Elasticity: spawn.Elasticity,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: new PhysicsTimestamps(
|
||||
Position: spawn.PositionSequence,
|
||||
Movement: spawn.MovementSequence,
|
||||
State: 0,
|
||||
Vector: 0,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: spawn.ServerControlSequence,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 0,
|
||||
Instance: spawn.InstanceSequence)),
|
||||
};
|
||||
}
|
||||
204
tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
Normal file
204
tests/AcDream.App.Tests/Net/LiveMovementStatsApplierTests.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System.Net;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F1 (2026-08-02): the movement-stats application seam driven through
|
||||
/// the REAL inbound chain that crashed the connected lifecycle gate —
|
||||
/// <c>ClientObjectTable.Ingest</c> → <c>ObjectAdded/ObjectUpdated</c> →
|
||||
/// <c>LiveSessionEventRouter.RecomputePlayerQualities</c> →
|
||||
/// <c>OnMovementStatsUpdated</c> → <see cref="LiveMovementStatsApplier"/> →
|
||||
/// <see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>.
|
||||
/// </summary>
|
||||
public sealed class LiveMovementStatsApplierTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x5000000Au;
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
public WorldSession Session { get; }
|
||||
public LiveSessionEventRouter Router { get; }
|
||||
public ClientObjectTable Objects { get; } = new();
|
||||
public RuntimeCharacterState Character { get; } = new();
|
||||
public RuntimeLocalPlayerMovementState Movement { get; } = new();
|
||||
public LiveMovementStatsApplier Applier { get; }
|
||||
public List<string> Log { get; } = [];
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9));
|
||||
// The REAL applier the factory binds (LiveSessionRuntimeFactory
|
||||
// constructs the same class over the same owner pair) with the
|
||||
// REAL character-bindings recompute callback shape.
|
||||
Applier = new LiveMovementStatsApplier(
|
||||
Movement,
|
||||
Character.MovementSkills,
|
||||
Log.Add);
|
||||
Router = new LiveSessionEventRouter(
|
||||
Session,
|
||||
new LiveEntitySessionSink(
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
||||
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
|
||||
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
||||
new LiveInventorySessionBindings(
|
||||
Objects,
|
||||
PlayerGuid: () => PlayerGuid,
|
||||
OnShortcuts: null,
|
||||
OnUseDone: null,
|
||||
ItemMana: new ItemManaState(),
|
||||
ExternalContainers: new ExternalContainerState()),
|
||||
new LiveCharacterSessionBindings(
|
||||
new CombatState(),
|
||||
Character,
|
||||
ResolveSkillFormulaBonus: null,
|
||||
OnSkillsUpdated: (_, _) => Applier.Apply("skills"),
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () => 0d,
|
||||
OnMovementStatsUpdated: () => Applier.Apply("stats")),
|
||||
new LiveSocialSessionBindings(
|
||||
new ChatLog(),
|
||||
new TurbineChatState(),
|
||||
new FriendsState(),
|
||||
new SquelchState()));
|
||||
Router.Attach();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests the player's own object row — the exact post-logout
|
||||
/// inbound-Create edge (<c>ApplyAcceptedSpawn</c> →
|
||||
/// <c>ClientObjectTable.Ingest</c>) that fired the crashing
|
||||
/// recompute in logs/connected-world-gate-20260802-122749.
|
||||
/// </summary>
|
||||
public void IngestPlayerRow(uint? pwdBitfield = null) =>
|
||||
Objects.Ingest(new WeenieData(
|
||||
Guid: PlayerGuid, Name: "+Acdream", Type: ItemType.Creature,
|
||||
WeenieClassId: 1u, IconId: 0, IconOverlayId: 0,
|
||||
IconUnderlayId: 0, Effects: 0,
|
||||
Value: null, StackSize: null, StackSizeMax: null, Burden: null,
|
||||
ContainerId: null, WielderId: null, ValidLocations: null,
|
||||
CurrentWieldedLocation: null, Priority: null,
|
||||
ItemsCapacity: null, ContainersCapacity: null,
|
||||
Structure: null, MaxStructure: null, Workmanship: null,
|
||||
PublicWeenieBitfield: pwdBitfield));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Router.Dispose();
|
||||
Session.Dispose();
|
||||
Movement.Dispose();
|
||||
Character.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerMovementController NewDormantRuntimeController()
|
||||
{
|
||||
PlayerMovementController controller =
|
||||
PlayerMovementController.CreatePublicationCandidate(
|
||||
new PhysicsEngine(),
|
||||
PlayerMovementConstructionOptions.Fallback);
|
||||
controller.SealPublicationCandidate();
|
||||
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostTeardownIngestRecomputeReportsTypedDropInsteadOfCrashing()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
// The session's authoritative skills were complete before teardown.
|
||||
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
||||
|
||||
// Post-teardown transient truth: a retired controller still
|
||||
// reachable through the displaced recompute callback — the state the
|
||||
// old direct path crashed on ("A sealed, retired, or discarded
|
||||
// Runtime movement controller cannot be mutated").
|
||||
PlayerMovementController controller = NewDormantRuntimeController();
|
||||
harness.Movement.Controller = controller;
|
||||
controller.ActivateRuntimePublication();
|
||||
controller.RetireRuntimePublication();
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.SetCharacterSkills(1, 1));
|
||||
|
||||
// The exact crash edge: a post-logout inbound Create's object-table
|
||||
// ingest fires the quality recompute through the real router.
|
||||
harness.IngestPlayerRow();
|
||||
|
||||
Assert.Contains(
|
||||
harness.Log,
|
||||
line => line.StartsWith(
|
||||
"player: dropped displaced movement stats",
|
||||
StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(
|
||||
harness.Log,
|
||||
line => line.StartsWith(
|
||||
"player: applied server movement",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DormantWindowIngestRecomputeAppliesToTheControllerThatGoesLive()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
||||
harness.Character.MovementSkills.UpdateStamina(37);
|
||||
|
||||
// The committed-but-unactivated first-entry window (activation
|
||||
// deferred on cell streaming) with inbound ingests still pumping.
|
||||
PlayerMovementController controller = NewDormantRuntimeController();
|
||||
harness.Movement.Controller = controller;
|
||||
Assert.True(controller.IsRuntimeOwnedDormant);
|
||||
|
||||
// BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) on the player's own row
|
||||
// exercises the full stat set through the recompute.
|
||||
harness.IngestPlayerRow(pwdBitfield: 0x28u);
|
||||
|
||||
Assert.Contains(
|
||||
harness.Log,
|
||||
line => line.StartsWith(
|
||||
"player: applied server movement stats",
|
||||
StringComparison.Ordinal));
|
||||
|
||||
// The same instance goes live at activation with the values already
|
||||
// current.
|
||||
controller.ActivateRuntimePublication();
|
||||
IWeenieObject weenie = controller.Motion.WeenieObj!;
|
||||
Assert.True(weenie.InqRunRate(out float runRate));
|
||||
Assert.True(runRate > 0f);
|
||||
Assert.Equal(
|
||||
ObjectInfoState.IsPK,
|
||||
controller.OwnPvpFlags & ObjectInfoState.IsPK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentControllerAndIncompleteSnapshotStaySilent()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
|
||||
// Incomplete snapshot (no PlayerDescription yet) with no controller:
|
||||
// byte-identical to the pre-F1 silent skip — no log line at all.
|
||||
harness.IngestPlayerRow();
|
||||
Assert.DoesNotContain(
|
||||
harness.Log,
|
||||
line => line.StartsWith("player:", StringComparison.Ordinal));
|
||||
|
||||
// Complete snapshot but still no controller (pre-first-entry):
|
||||
// still the silent skip.
|
||||
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedNoController,
|
||||
harness.Applier.Apply("stats"));
|
||||
Assert.DoesNotContain(
|
||||
harness.Log,
|
||||
line => line.StartsWith("player:", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +257,12 @@ public sealed class LiveSessionResetPlanTests
|
|||
spatial,
|
||||
new FailingOnceResources(),
|
||||
runtime.EntityObjects);
|
||||
live.RegisterLiveEntity(Spawn(player, 1, 1, 0x01010001u));
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F1 (2026-08-02): source pins for the App-side halves of the
|
||||
/// movement-owner application seams whose production graphs have no
|
||||
/// focused harness (the inbound network-update controller's dependency
|
||||
/// set is composition-only). The Runtime lifecycle matrices carry the
|
||||
/// behavioral coverage; these pins keep the App wiring routed through the
|
||||
/// owner's typed entries instead of the throwing direct mutations that
|
||||
/// crashed the connected lifecycle gate twice
|
||||
/// (logs/connected-world-gate-20260802-122749 — SetCharacterSkills;
|
||||
/// logs/connected-world-gate-20260802-125907 — ApplyPhysicsState).
|
||||
/// </summary>
|
||||
public sealed class C3cF1ProductionWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void LocalPlayerInboundSetState_RoutesThroughTheTypedOwnerEntry()
|
||||
{
|
||||
string source = ReadSource(
|
||||
"Physics",
|
||||
"LiveEntityNetworkUpdateController.cs");
|
||||
|
||||
Assert.Single(
|
||||
Regex.Matches(source, @"ApplyServerPhysicsState\(")
|
||||
.Cast<Match>());
|
||||
Assert.DoesNotContain(
|
||||
".ApplyPhysicsState(",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ReadSource(params string[] relativePath)
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return File.ReadAllText(Path.Combine(
|
||||
directory.FullName,
|
||||
"src",
|
||||
"AcDream.App",
|
||||
Path.Combine(relativePath)));
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -7,20 +7,40 @@ public sealed class Issue270ProductionWiringTests
|
|||
[Fact]
|
||||
public void MovementStats_UseOneEdgeTrackerAndResetItWithTheSession()
|
||||
{
|
||||
string source = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
|
||||
// C3c-F1 (2026-08-02): the #270 invariant is unchanged — exactly one
|
||||
// stamina-exhaustion edge tracker, exhaustion dispatched once on the
|
||||
// edge, tracker reset with the session — but the wiring moved from
|
||||
// the factory's deleted ApplyMovementStats body into
|
||||
// LiveMovementStatsApplier, which routes through the Runtime
|
||||
// movement owner's typed seam instead of touching the controller.
|
||||
string applier = ReadSource("Net", "LiveMovementStatsApplier.cs");
|
||||
string factory = ReadSource("Net", "LiveSessionRuntimeFactory.cs");
|
||||
|
||||
Assert.Contains(
|
||||
"_staminaExhaustion.Observe(snapshot.CurrentStamina)",
|
||||
source,
|
||||
applier,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Single(
|
||||
Regex.Matches(
|
||||
source,
|
||||
@"controller!\.Motion\.ReportExhaustion\(\);")
|
||||
applier,
|
||||
@"_movement\.ReportExhaustion\(\);")
|
||||
.Cast<Match>());
|
||||
Assert.Contains(
|
||||
"_staminaExhaustion.Reset();",
|
||||
source,
|
||||
applier,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_movementStats.Reset();",
|
||||
factory,
|
||||
StringComparison.Ordinal);
|
||||
// The factory keeps zero direct controller mutations: both stat
|
||||
// callbacks route through the applier's seam.
|
||||
Assert.Equal(
|
||||
2,
|
||||
Regex.Matches(factory, @"_movementStats\.Apply\(").Count);
|
||||
Assert.DoesNotContain(
|
||||
"Motion.ReportExhaustion",
|
||||
factory,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +58,11 @@ public sealed class Issue270ProductionWiringTests
|
|||
Assert.Equal(
|
||||
3,
|
||||
Regex.Matches(source, @"SeedRemoteSpawnPlacement\(").Count);
|
||||
// C3c-F5: the settle helper moved to Core (SpawnPlacementSettler) so
|
||||
// the local player's Runtime first-entry activation shares the same
|
||||
// tested compressed-first-gravity-frame sweep.
|
||||
Assert.Contains(
|
||||
"RemoteSpawnPlacementSettler.TrySettle(",
|
||||
"SpawnPlacementSettler.TrySettle(",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,12 @@ public sealed class LiveEntityInboundAuthorityGateTests
|
|||
out AcceptedPositionNetworkUpdate accepted));
|
||||
|
||||
Assert.Same(registration.Canonical, accepted.Canonical);
|
||||
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
|
||||
// C3c: the accepted Position of an entity whose initial-create
|
||||
// residence is still pending is admitted into the residence FIFO —
|
||||
// its merge (and the position-authority advance) commits at the
|
||||
// executor drain, so the gate's captured authority is the
|
||||
// admission-time version, not a post-apply bump.
|
||||
Assert.Equal((ulong)1, accepted.PositionAuthorityVersion);
|
||||
Assert.Equal(1, publishCount);
|
||||
Assert.False(runtime.TryGetRecord(Guid, out _));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1112,7 +1112,10 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
0f,
|
||||
0f,
|
||||
0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
// C3c: residence admission requires the nested block's Instance
|
||||
// timestamp to agree with the flattened InstanceSequence.
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
1, 1, 1, 1, 0, 1, 0, 1, instanceSequence);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)state,
|
||||
Position: serverPosition,
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
ChildInstanceSequence: 1,
|
||||
ChildPositionSequence: 1);
|
||||
|
||||
fixture.CompleteFirstEntry();
|
||||
Assert.True(fixture.Live.TryApplyCreateParent(update, out _));
|
||||
fixture.Controller.OnCreateParentAccepted(update);
|
||||
|
||||
|
|
@ -509,13 +510,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1);
|
||||
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
||||
0x70000271u,
|
||||
generation: 1) with
|
||||
generation: 1);
|
||||
childSpawn = childSpawn with
|
||||
{
|
||||
Position = null,
|
||||
ParentGuid = parent.ServerGuid,
|
||||
ParentLocation = 0,
|
||||
PlacementId = null,
|
||||
PositionSequence = 0,
|
||||
Physics = childSpawn.Physics!.Value with
|
||||
{
|
||||
Position = null,
|
||||
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
|
||||
AnimationFrame = null,
|
||||
},
|
||||
};
|
||||
fixture.Live.RegisterLiveEntity(childSpawn);
|
||||
fixture.Controller.OnSpawn(childSpawn);
|
||||
|
|
@ -558,13 +566,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1);
|
||||
WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
|
||||
0x70000273u,
|
||||
generation: 1) with
|
||||
generation: 1);
|
||||
childSpawn = childSpawn with
|
||||
{
|
||||
Position = null,
|
||||
ParentGuid = parent.ServerGuid,
|
||||
ParentLocation = 0,
|
||||
PlacementId = 0,
|
||||
PositionSequence = 0,
|
||||
Physics = childSpawn.Physics!.Value with
|
||||
{
|
||||
Position = null,
|
||||
Parent = new PhysicsAttachment(parent.ServerGuid, 0u),
|
||||
AnimationFrame = 0,
|
||||
},
|
||||
};
|
||||
fixture.Live.RegisterLiveEntity(childSpawn);
|
||||
fixture.Controller.OnSpawn(childSpawn);
|
||||
|
|
@ -577,13 +592,20 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
Assert.Null(entity.PaletteOverride);
|
||||
WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData(
|
||||
0x70000274u,
|
||||
generation: 1) with
|
||||
generation: 1);
|
||||
grandchildSpawn = grandchildSpawn with
|
||||
{
|
||||
Position = null,
|
||||
ParentGuid = child.ServerGuid,
|
||||
ParentLocation = 0,
|
||||
PlacementId = 0,
|
||||
PositionSequence = 0,
|
||||
Physics = grandchildSpawn.Physics!.Value with
|
||||
{
|
||||
Position = null,
|
||||
Parent = new PhysicsAttachment(child.ServerGuid, 0u),
|
||||
AnimationFrame = 0,
|
||||
},
|
||||
};
|
||||
fixture.Live.RegisterLiveEntity(grandchildSpawn);
|
||||
fixture.Controller.OnSpawn(grandchildSpawn);
|
||||
|
|
@ -734,6 +756,8 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
Position = null,
|
||||
PositionSequence = 1,
|
||||
PlacementId = 0,
|
||||
ParentGuid = parent.ServerGuid,
|
||||
ParentLocation = 0,
|
||||
Physics = physics,
|
||||
};
|
||||
|
||||
|
|
@ -1192,6 +1216,50 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
private const uint Cell = 0x01010001u;
|
||||
private readonly DeferredLiveEntityRuntimeComponentLifecycle _lifecycle = new();
|
||||
private readonly Setup _setup;
|
||||
private readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController _firstEntry;
|
||||
|
||||
private sealed class NullCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision>.Missing;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal ControllerFixture(
|
||||
Func<LiveEntityRecord, ulong, ulong, ExactProjectionWithdrawalOutcome>
|
||||
|
|
@ -1201,10 +1269,24 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
(Cell & 0xFFFF0000u) | 0xFFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Live = LiveEntityRuntimeFixture.Create(
|
||||
EntityObjects = new RuntimeEntityObjectLifetime();
|
||||
EntityObjects.BindEventContext(
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
_firstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController(
|
||||
EntityObjects,
|
||||
new AcDream.Runtime.GameRuntimeClock(),
|
||||
new NullCollisionSource(),
|
||||
() => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new AcDream.Runtime.Gameplay.RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f,
|
||||
1.835f,
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
Live = new LiveEntityRuntime(
|
||||
Spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
|
||||
_lifecycle);
|
||||
_lifecycle,
|
||||
EntityObjects);
|
||||
_setup = new Setup
|
||||
{
|
||||
HoldingLocations =
|
||||
|
|
@ -1231,6 +1313,21 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
}
|
||||
|
||||
internal GpuWorldState Spatial { get; } = new();
|
||||
internal RuntimeEntityObjectLifetime EntityObjects { get; }
|
||||
|
||||
/// <summary>
|
||||
/// C3c: completes fresh residences through the first-entry
|
||||
/// conductors (a celless route performs no SetPosition, so its drain
|
||||
/// completes synchronously), releasing the lease so legacy direct
|
||||
/// CreateParent/Parent application stays valid post-flip. The
|
||||
/// controller is constructed lazily but binds no notification of its
|
||||
/// own tracking value until first use, so it sweeps active leases
|
||||
/// directly instead.
|
||||
/// </summary>
|
||||
internal void CompleteFirstEntry()
|
||||
{
|
||||
_firstEntry.DriveAll();
|
||||
}
|
||||
internal ClientObjectTable Objects { get; } = new();
|
||||
internal EntityEffectPoseRegistry Poses { get; } = new();
|
||||
internal LiveEntityRuntime Live { get; }
|
||||
|
|
@ -1254,7 +1351,13 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
{
|
||||
WorldSession.EntitySpawn spawn = SpawnData(guid, generation);
|
||||
if (!hasPosition)
|
||||
spawn = spawn with { Position = null };
|
||||
{
|
||||
spawn = spawn with
|
||||
{
|
||||
Position = null,
|
||||
Physics = spawn.Physics!.Value with { Position = null },
|
||||
};
|
||||
}
|
||||
if (!hasSetup)
|
||||
spawn = spawn with { SetupTableId = null };
|
||||
return Assert.IsType<RuntimeEntityRecord>(
|
||||
|
|
@ -1288,21 +1391,59 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
return record;
|
||||
}
|
||||
|
||||
internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(
|
||||
Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f),
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "attached fixture",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: generation);
|
||||
internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation)
|
||||
{
|
||||
// C3c: residence admission freezes the raw create and requires
|
||||
// the flattened parser projections to agree with the nested
|
||||
// PhysicsDesc block (HasConsistentCreateIdentityAndParent).
|
||||
var position = new CreateObject.ServerPosition(
|
||||
Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0u,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: null,
|
||||
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: new PhysicsTimestamps(
|
||||
Position: 0,
|
||||
Movement: 0,
|
||||
State: 0,
|
||||
Vector: 0,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 0,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 0,
|
||||
Instance: generation));
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "attached fixture",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: generation,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
internal void InstallAttached(
|
||||
LiveEntityRecord parent,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public sealed class LiveAppearanceAnimationTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: 1);
|
||||
InstanceSequence: 1).WithConsistentPhysics();
|
||||
LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(
|
||||
spawn,
|
||||
id => Entity(id, 0x01000001u, guid));
|
||||
|
|
@ -175,7 +175,7 @@ public sealed class LiveAppearanceAnimationTests
|
|||
Assert.Equal(1, registry.RetainedRegistrationCount);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new(
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell, ushort instance) => new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(cell, 1f, 1f, 1f, 1f, 0f, 0f, 0f),
|
||||
0x02000010u,
|
||||
|
|
@ -188,7 +188,7 @@ public sealed class LiveAppearanceAnimationTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: instance);
|
||||
InstanceSequence: instance).WithConsistentPhysics();
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,22 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
|
|||
publishAppearance: _ =>
|
||||
{
|
||||
operations.Add("appearance");
|
||||
runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" });
|
||||
// C3c: a fresher same-generation CreateObject no longer
|
||||
// advances create authority at registration — while a
|
||||
// residence is active it is FIFO-staged and its authority
|
||||
// advance lands at the executor drain's WeenieDescription
|
||||
// stage (AdvanceCreateAuthority). Model that exact advance
|
||||
// directly so the between-stage revalidation guard stays
|
||||
// covered.
|
||||
// C3c-R1 F3: honest MODEL of the executor drain's advance
|
||||
// (ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests); a
|
||||
// nested production OnCreate can no longer reach it —
|
||||
// post-residence registration is description-only
|
||||
// (RuntimeEntityObjectLifetime :660-665,
|
||||
// !beginInitialResidence gate) and ConsumeExecuted already
|
||||
// removed the completed residence entry.
|
||||
record.Canonical.AdvanceCreateAuthority();
|
||||
return true;
|
||||
},
|
||||
publishCurrentSnapshot: _ => operations.Add("current"),
|
||||
|
|
@ -315,7 +330,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
|
|||
return record;
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn() => new(
|
||||
private static WorldSession.EntitySpawn Spawn() => new WorldSession.EntitySpawn(
|
||||
Guid,
|
||||
new CreateObject.ServerPosition(
|
||||
Cell,
|
||||
|
|
@ -336,7 +351,7 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: 1);
|
||||
InstanceSequence: 1).WithConsistentPhysics();
|
||||
|
||||
private static LiveEntityAnimationState AnimationState(
|
||||
WorldEntity entity,
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ public sealed class LiveEntityLightControllerTests
|
|||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1);
|
||||
PositionSequence: 1).WithConsistentPhysics();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -278,6 +278,15 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
[Fact]
|
||||
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
|
||||
{
|
||||
// C3c (clause 2 — undriven-residence semantics): an initial world
|
||||
// Create now begins the canonical initial-create residence, whose
|
||||
// admission requires a live session generation. Both hosts here
|
||||
// deliberately carry the zero generation of a session-less runtime,
|
||||
// so BOTH refuse the Create transactionally with the identical
|
||||
// structural error and publish no entity-object trace at all. The
|
||||
// full driven-flow direct-vs-graphical parity (residence ->
|
||||
// conductor -> receipts) is covered by the C3c first-entry
|
||||
// integration tests.
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
|
||||
var direct = new RuntimeEntityObjectLifetime();
|
||||
|
|
@ -286,148 +295,28 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
using IDisposable directSubscription =
|
||||
direct.Events.Subscribe(directTrace);
|
||||
|
||||
RuntimeEntityRegistrationResult directRegistration =
|
||||
direct.RegisterEntity(spawn);
|
||||
RuntimeEntityRecord directCanonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical);
|
||||
direct.Objects.AddOrUpdate(Object(spawn));
|
||||
direct.Objects.MoveItem(
|
||||
spawn.Guid,
|
||||
Harness.PlayerGuid,
|
||||
newSlot: 3);
|
||||
var objDesc = new ObjDescEvent.Parsed(
|
||||
spawn.Guid,
|
||||
new CreateObject.ModelData(
|
||||
0x04000001u,
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.AnimPartChange>()),
|
||||
spawn.InstanceSequence,
|
||||
ObjDescSequence: 2);
|
||||
Assert.True(direct.TryApplyObjDesc(
|
||||
objDesc,
|
||||
acknowledgeProjection: null,
|
||||
out _));
|
||||
var motion = new WorldSession.EntityMotionUpdate(
|
||||
spawn.Guid,
|
||||
new CreateObject.ServerMotionState(0x3D, 0x11),
|
||||
spawn.InstanceSequence,
|
||||
MovementSequence: 2,
|
||||
ServerControlSequence: 2,
|
||||
IsAutonomous: false);
|
||||
Assert.True(direct.TryApplyMotion(
|
||||
motion,
|
||||
retainPayload: true,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _));
|
||||
var vector = new VectorUpdate.Parsed(
|
||||
spawn.Guid,
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(0f, 0f, 0.25f),
|
||||
spawn.InstanceSequence,
|
||||
VectorSequence: 2);
|
||||
Assert.True(direct.TryApplyVector(
|
||||
vector,
|
||||
acknowledgeProjection: null,
|
||||
out _));
|
||||
var state = new SetState.Parsed(
|
||||
spawn.Guid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Hidden),
|
||||
spawn.InstanceSequence,
|
||||
StateSequence: 2);
|
||||
Assert.True(direct.TryApplyState(
|
||||
state,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(direct.CommitChildNoDraw(
|
||||
directCanonical,
|
||||
noDraw: true));
|
||||
Assert.True(direct.CommitChildNoDraw(
|
||||
directCanonical,
|
||||
noDraw: false));
|
||||
WorldSession.EntityPositionUpdate position =
|
||||
Position(spawn.Guid, spawn.InstanceSequence);
|
||||
Assert.True(direct.TryApplyPosition(
|
||||
position,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
projectionRequiresTeleportHook: false,
|
||||
acknowledgeProjection: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(direct.CommitRebucket(
|
||||
directCanonical,
|
||||
0x12360001u,
|
||||
0x1236FFFFu));
|
||||
Assert.True(direct.TryApplyPickup(
|
||||
new PickupEvent.Parsed(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
PositionSequence: 3),
|
||||
acknowledgeProjection: null,
|
||||
out _));
|
||||
Assert.True(direct.TryAcceptDelete(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
removeRetainedObject: true,
|
||||
out RuntimeEntityDeleteAcceptance directDelete));
|
||||
direct.CompleteAcceptedDelete(directDelete);
|
||||
Assert.Null(direct.RetireCanonicalOnly(directCanonical));
|
||||
InvalidOperationException directRefusal =
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
direct.RegisterEntityWithInitialResidence(
|
||||
spawn,
|
||||
isLocalPlayer: false));
|
||||
|
||||
using var graphical = new Harness();
|
||||
var graphicalTrace = new EntityObjectTrace();
|
||||
using IDisposable graphicalSubscription =
|
||||
graphical.EntityObjects.Events.Subscribe(graphicalTrace);
|
||||
_ = graphical.Entities.RegisterAndMaterializeProjection(spawn);
|
||||
graphical.Objects.AddOrUpdate(Object(spawn));
|
||||
graphical.Objects.MoveItem(
|
||||
spawn.Guid,
|
||||
Harness.PlayerGuid,
|
||||
newSlot: 3);
|
||||
Assert.True(graphical.Entities.TryApplyObjDesc(
|
||||
objDesc,
|
||||
out _));
|
||||
Assert.True(graphical.Entities.TryApplyMotion(
|
||||
motion,
|
||||
retainPayload: true,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(graphical.Entities.TryApplyVector(vector, out _));
|
||||
Assert.True(graphical.Entities.TryApplyState(state, out _, out _));
|
||||
Assert.True(graphical.Entities.SetAttachedChildNoDraw(
|
||||
spawn.Guid,
|
||||
noDraw: true));
|
||||
Assert.True(graphical.Entities.SetAttachedChildNoDraw(
|
||||
spawn.Guid,
|
||||
noDraw: false));
|
||||
Assert.True(graphical.Entities.TryApplyPosition(
|
||||
position,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
Assert.True(graphical.Entities.RebucketLiveEntity(
|
||||
spawn.Guid,
|
||||
0x12360001u));
|
||||
Assert.True(graphical.Entities.TryApplyPickup(
|
||||
new PickupEvent.Parsed(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
PositionSequence: 3),
|
||||
out _));
|
||||
Assert.True(graphical.Entities.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
|
||||
isLocalPlayer: false,
|
||||
removeRetainedObject: true));
|
||||
|
||||
InvalidOperationException graphicalRefusal =
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
graphical.Entities.RegisterAndMaterializeProjection(spawn));
|
||||
|
||||
Assert.Contains(
|
||||
"cannot acquire a structurally valid initial residence lease",
|
||||
directRefusal.Message,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(directRefusal.Message, graphicalRefusal.Message);
|
||||
Assert.Equal(directTrace.Entries, graphicalTrace.Entries);
|
||||
Assert.Empty(directTrace.Entries);
|
||||
Assert.Equal(0, direct.Entities.Count);
|
||||
Assert.Equal(0, direct.Objects.ObjectCount);
|
||||
Assert.Equal(0, graphical.EntityObjects.Entities.Count);
|
||||
|
|
@ -438,6 +327,11 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
|
||||
{
|
||||
using var harness = new Harness();
|
||||
// C3c: registration begins the canonical initial-create residence,
|
||||
// whose admission requires a live session generation.
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
harness.Runtime.Session.Start(harness.Runtime.Generation).Status);
|
||||
var throwing = new ThrowingEntityObserver();
|
||||
var recording = new RuntimeTraceRecorder();
|
||||
IDisposable first = harness.Runtime.Subscribe(throwing);
|
||||
|
|
|
|||
|
|
@ -599,7 +599,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
new Vector3(x, y, z),
|
||||
Quaternion.Identity));
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new(
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: new CreateObject.ServerPosition(
|
||||
cell,
|
||||
|
|
@ -619,7 +619,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Name: "player",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
MotionTableId: null).WithConsistentPhysics();
|
||||
|
||||
private sealed class NullResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
|
|
|
|||
|
|
@ -544,7 +544,7 @@ public sealed class StreamingFrameControllerTests
|
|||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1);
|
||||
PositionSequence: 1).WithConsistentPhysics();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LandblockStreamResult> Drain(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 F3 (coordinator resolution, 2026-08-02): the create-authority
|
||||
/// drift probes in the expectation-item 6/8 tests
|
||||
/// (LiveEntityHydrationControllerTests + LiveEntityCreateSupersessionRecoveryTests)
|
||||
/// hand-call <c>record.Canonical.AdvanceCreateAuthority()</c> as an HONEST
|
||||
/// MODEL of the executor drain's advance — the SOLE remaining production
|
||||
/// site that advances create authority for an existing incarnation. A
|
||||
/// nested production OnCreate can no longer produce that drift:
|
||||
/// post-residence ExistingGeneration registration is description-only
|
||||
/// (RuntimeEntityObjectLifetime gates the advance on
|
||||
/// <c>!beginInitialResidence</c>) and <c>ConsumeExecuted</c> removes the
|
||||
/// completed residence entry at Released, closing the FIFO-adoption path
|
||||
/// (empirically confirmed: the restored nested-OnCreate probe produced no
|
||||
/// drift and no CreateSupersessionRecovery). This pin flags the model as
|
||||
/// STALE if the production site ever moves or loses the advance — the
|
||||
/// item 6/8 probes must be re-derived from wherever it goes.
|
||||
/// </summary>
|
||||
public sealed class C3cR1F3DriftModelSourcePinTests
|
||||
{
|
||||
[Fact]
|
||||
public void HandCalledDriftProbe_StillModelsTheExecutorDrainAdvance()
|
||||
{
|
||||
string executor = ReadRuntimeSource(
|
||||
"Entities",
|
||||
"RuntimeInitialCreateContinuationExecutor.cs");
|
||||
|
||||
// Exactly one production advance, and it lives inside the
|
||||
// WeenieDescription drain stage the probes model.
|
||||
Assert.Single(
|
||||
Regex.Matches(executor, @"_entities\.AdvanceCreateAuthority\(")
|
||||
.Cast<Match>());
|
||||
Assert.Matches(
|
||||
new Regex(
|
||||
@"private bool ApplyWeenieDescriptionAction[\s\S]{0,6000}?"
|
||||
+ @"_entities\.AdvanceCreateAuthority\(canonical\);"),
|
||||
executor);
|
||||
|
||||
// The registration-time advance stays gated OFF the residence
|
||||
// route — the reason a nested production OnCreate cannot reach the
|
||||
// modeled drift.
|
||||
string lifetime = ReadRuntimeSource(
|
||||
"Entities",
|
||||
"RuntimeEntityObjectLifetime.cs");
|
||||
Assert.Matches(
|
||||
new Regex(
|
||||
@"if \(!beginInitialResidence\)\s*"
|
||||
+ @"Entities\.AdvanceCreateAuthority\(retained\);"),
|
||||
lifetime);
|
||||
}
|
||||
|
||||
private static string ReadRuntimeSource(params string[] relativePath)
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return File.ReadAllText(Path.Combine(
|
||||
directory.FullName,
|
||||
"src",
|
||||
"AcDream.Runtime",
|
||||
Path.Combine(relativePath)));
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
|
|||
}
|
||||
|
||||
private static WorldSession.EntitySpawn CreateSpawn(uint guid) =>
|
||||
new(
|
||||
new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
|
|
@ -157,5 +157,5 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: 1);
|
||||
InstanceSequence: 1).WithConsistentPhysics();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,12 +370,14 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
LiveEntityRecord record = fixture.Record;
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
var body = new PhysicsBody();
|
||||
Assert.Same(
|
||||
body,
|
||||
fixture.Runtime.GetOrCreatePhysicsBody(
|
||||
record.ServerGuid,
|
||||
_ => body));
|
||||
// C3c/C3b: the first-entry conductor constructs the canonical
|
||||
// physics body at Create (retail ACCObjectMaint::CreateObject /
|
||||
// set_description). Capture that existing body — the identity the
|
||||
// pickup/re-enter cycle must preserve — instead of seeding one.
|
||||
PhysicsBody body = fixture.Runtime.GetOrCreatePhysicsBody(
|
||||
record.ServerGuid,
|
||||
static _ => throw new InvalidOperationException(
|
||||
"The conductor-built canonical body should already exist."));
|
||||
fixture.Relationships.OnUnparentAction = _ =>
|
||||
fixture.Runtime.WithdrawLiveEntityProjection(record)
|
||||
? ChildUnparentDisposition.Completed
|
||||
|
|
@ -962,6 +964,14 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
|
||||
// Local-player records ordinarily do not rebucket from streaming
|
||||
// callbacks; an incomplete initial transaction must still recover.
|
||||
// C3c: the failed Create transaction unwound before OnCreateCore's
|
||||
// own drive pump ran, leaving the residence pending with FullCellId
|
||||
// 0 (streaming callbacks key candidates off the committed cell). In
|
||||
// production the per-frame first-entry pump completes the conductor
|
||||
// independently of the failed hydration transaction; model that
|
||||
// pump here, then let the streaming callback recover the partial
|
||||
// projection exactly as before.
|
||||
fixture.FirstEntry.DriveAll();
|
||||
fixture.Controller.OnLandblockLoaded(Cell);
|
||||
|
||||
Assert.Same(partial, record.WorldEntity);
|
||||
|
|
@ -1042,7 +1052,13 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
||||
Assert.True(fixture.Record.InitialHydrationCompleted);
|
||||
Assert.Single(fixture.Materializer.Calls);
|
||||
Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[0]);
|
||||
// C3c: the nested fresher same-generation Create is admitted into
|
||||
// the outer create's ACTIVE residence FIFO (AD-59) and its facts
|
||||
// commit at the executor drain, in array order. The single
|
||||
// materialization therefore runs from the admission-frozen seq-1
|
||||
// create; the seq-2 facts (including the name asserted above) land
|
||||
// through the drain and bind via the completion receipt.
|
||||
Assert.Equal((ushort)1, fixture.Materializer.PositionSequences[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1114,10 +1130,16 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Assert.Equal((ushort)1, fixture.Record.Generation);
|
||||
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
||||
Assert.True(fixture.Record.InitialHydrationCompleted);
|
||||
Assert.Equal((ushort)3, fixture.Materializer.PositionSequences[^1]);
|
||||
// C3c: the nested fresher same-generation Create is admitted into
|
||||
// the replacement generation's ACTIVE residence FIFO (AD-59) and its
|
||||
// facts commit at the executor drain, in array order. The
|
||||
// materialization therefore runs from the admission-frozen seq-2
|
||||
// replacement create; the seq-3 facts (including the name asserted
|
||||
// above) land through the drain and never re-materialize.
|
||||
Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[^1]);
|
||||
Assert.DoesNotContain(
|
||||
(ushort)2,
|
||||
fixture.Materializer.PositionSequences.Skip(1));
|
||||
(ushort)3,
|
||||
fixture.Materializer.PositionSequences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1210,11 +1232,19 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
|
||||
Assert.True(record.InitialHydrationCompleted);
|
||||
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
||||
Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences);
|
||||
// C3c: a post-residence same-generation Create is description-only
|
||||
// at registration — its position churn flows through the
|
||||
// freshness-gated events tail (which is what makes RecoverProjection
|
||||
// return false above), and create authority advances only inside the
|
||||
// residence transaction. No drift means no nested
|
||||
// CreateSupersessionRecovery re-materialization: the recovery's own
|
||||
// SpatialRecovery attempt stays the last call at the original
|
||||
// installed version.
|
||||
Assert.Equal([1, 1], fixture.Materializer.PositionSequences);
|
||||
Assert.Equal(
|
||||
LiveProjectionPurpose.CreateSupersessionRecovery,
|
||||
LiveProjectionPurpose.SpatialRecovery,
|
||||
fixture.Materializer.Calls[^1].Purpose);
|
||||
Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion);
|
||||
Assert.Equal(1UL, fixture.Materializer.InstalledCreateIntegrationVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -1236,10 +1266,23 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
if (refreshed || spawn.PositionSequence != 1)
|
||||
return;
|
||||
refreshed = true;
|
||||
fixture.Controller.OnCreate(Spawn(
|
||||
Generation: 1,
|
||||
PositionSequence: 2,
|
||||
Name: "recovery v2"));
|
||||
// C3c: a fresher same-generation CreateObject no longer advances
|
||||
// create authority at registration (its advance lands at the
|
||||
// residence drain's WeenieDescription stage). Model that exact
|
||||
// advance directly so the drift-retry machinery under test still
|
||||
// fires.
|
||||
// C3c-R1 F3 (coordinator resolution): this hand-call is an
|
||||
// honest MODEL of the executor drain's advance
|
||||
// (RuntimeInitialCreateContinuationExecutor
|
||||
// .ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests). A nested
|
||||
// production OnCreate can no longer reach it here:
|
||||
// post-residence ExistingGeneration registration is
|
||||
// description-only (RuntimeEntityObjectLifetime gates the
|
||||
// advance on !beginInitialResidence, :660-665) and
|
||||
// ConsumeExecuted already removed the completed residence
|
||||
// entry, closing the FIFO-adoption path.
|
||||
record.Canonical.AdvanceCreateAuthority();
|
||||
};
|
||||
fixture.Materializer.ThrowAfterMaterializePurposeOnce =
|
||||
LiveProjectionPurpose.CreateSupersessionRecovery;
|
||||
|
|
@ -1270,8 +1313,12 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
|
||||
Assert.False(record.CreateProjectionSynchronizationPending);
|
||||
Assert.Same(retained, record.WorldEntity);
|
||||
// C3c: the retry retransmit (a post-residence same-generation
|
||||
// Create) no longer advances create authority itself, so both retry
|
||||
// paths install the drift probe's version (2), not a
|
||||
// retransmit-advanced 3.
|
||||
Assert.Equal(
|
||||
retryFromLandblock ? 2UL : 3UL,
|
||||
2UL,
|
||||
fixture.Materializer.InstalledCreateIntegrationVersion);
|
||||
Assert.Equal(
|
||||
LiveProjectionPurpose.CreateSupersessionRecovery,
|
||||
|
|
@ -1357,10 +1404,23 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
if (refreshed || spawn.PositionSequence != 1)
|
||||
return;
|
||||
refreshed = true;
|
||||
fixture.Controller.OnCreate(Spawn(
|
||||
Generation: 1,
|
||||
PositionSequence: 2,
|
||||
Name: "ready v2"));
|
||||
// C3c: a fresher same-generation CreateObject no longer advances
|
||||
// create authority at registration (its advance lands at the
|
||||
// residence drain's WeenieDescription stage). Model that exact
|
||||
// advance directly so the drift-retry machinery under test still
|
||||
// fires.
|
||||
// C3c-R1 F3 (coordinator resolution): this hand-call is an
|
||||
// honest MODEL of the executor drain's advance
|
||||
// (RuntimeInitialCreateContinuationExecutor
|
||||
// .ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests). A nested
|
||||
// production OnCreate can no longer reach it here:
|
||||
// post-residence ExistingGeneration registration is
|
||||
// description-only (RuntimeEntityObjectLifetime gates the
|
||||
// advance on !beginInitialResidence, :660-665) and
|
||||
// ConsumeExecuted already removed the completed residence
|
||||
// entry, closing the FIFO-adoption path.
|
||||
record.Canonical.AdvanceCreateAuthority();
|
||||
};
|
||||
fixture.Ready.FailPublishCount = 1;
|
||||
|
||||
|
|
@ -1486,6 +1546,12 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
{
|
||||
const uint parentGuid = 0x70000002u;
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
// C3c: a Create whose parent is not addressable is now queued under
|
||||
// the parent's GUID (retail QueueBlobForObject) instead of applying
|
||||
// immediately. Register the parent so the nested parented Create
|
||||
// routes exactly as before.
|
||||
fixture.Runtime.RegisterLiveEntity(
|
||||
Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid });
|
||||
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
||||
LiveEntityRecord record = fixture.Record;
|
||||
WorldEntity retained = record.WorldEntity!;
|
||||
|
|
@ -1522,6 +1588,17 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
record,
|
||||
positionVersion));
|
||||
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record));
|
||||
// C3c: mirror the production relationship owner
|
||||
// (EquippedChildRenderController.TryAttach), which converts a
|
||||
// residence-managed child's sticky residence to LegacyImmediate
|
||||
// at the world -> attached kind transition.
|
||||
if (record.MaterializationResidence is
|
||||
AcDream.App.World.LiveEntityMaterializationResidence
|
||||
.AwaitRuntimePlacement)
|
||||
{
|
||||
record.MaterializationResidence = AcDream.App.World
|
||||
.LiveEntityMaterializationResidence.LegacyImmediate;
|
||||
}
|
||||
WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity(
|
||||
Guid,
|
||||
Cell,
|
||||
|
|
@ -1540,6 +1617,12 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
fixture.Controller.OnCreate(CelllessSpawn(
|
||||
PositionSequence: 2,
|
||||
parentGuid));
|
||||
// C3c: a post-residence same-generation Create no longer
|
||||
// advances create authority at registration (the advance lands
|
||||
// at a residence drain's WeenieDescription stage). Model that
|
||||
// advance directly so the supersession-recovery machinery under
|
||||
// test still fires and completes at the attached-ready boundary.
|
||||
record.Canonical.AdvanceCreateAuthority();
|
||||
};
|
||||
|
||||
Assert.False(fixture.Controller.RecoverProjection(
|
||||
|
|
@ -1575,6 +1658,25 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
using var fixture = new Fixture(
|
||||
originKnown: true,
|
||||
playerGuid: retryFromLandblock ? Guid : 0u);
|
||||
// C3c: a Create whose parent is not addressable is now queued under
|
||||
// the parent's GUID (retail QueueBlobForObject) instead of applying
|
||||
// immediately. Register the parent so the initial parented Create
|
||||
// routes exactly as before — placed in a DIFFERENT landblock so this
|
||||
// scaffolding identity is not itself a candidate for the recovered
|
||||
// landblock's projection sweep (the child's RegisterCount assertions
|
||||
// count only the child's resources).
|
||||
WorldSession.EntitySpawn parentSpawn =
|
||||
Spawn(Generation: 1, PositionSequence: 1) with { Guid = parentGuid };
|
||||
var parentPosition = new CreateObject.ServerPosition(
|
||||
0x01020001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
fixture.Runtime.RegisterLiveEntity(parentSpawn with
|
||||
{
|
||||
Position = parentPosition,
|
||||
Physics = parentSpawn.Physics!.Value with
|
||||
{
|
||||
Position = parentPosition,
|
||||
},
|
||||
});
|
||||
fixture.Ready.FailPublishCount = 2;
|
||||
fixture.Network.ApplyAction = events =>
|
||||
{
|
||||
|
|
@ -1659,8 +1761,20 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
if (!replaced && replacementStage == stage)
|
||||
{
|
||||
replaced = true;
|
||||
fixture.Runtime.RegisterLiveEntity(
|
||||
Spawn(Generation: 1, PositionSequence: 2));
|
||||
// C3c: a fresher same-generation CreateObject no longer
|
||||
// advances create authority at registration — its advance
|
||||
// lands at the residence drain's WeenieDescription stage.
|
||||
// Model that exact advance directly so the between-stage
|
||||
// revalidation guard stays covered.
|
||||
// C3c-R1 F3: honest MODEL of the executor drain's advance
|
||||
// (ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests); a
|
||||
// nested production OnCreate can no longer reach it —
|
||||
// post-residence registration is description-only
|
||||
// (RuntimeEntityObjectLifetime :660-665,
|
||||
// !beginInitialResidence gate) and ConsumeExecuted already
|
||||
// removed the completed residence entry.
|
||||
expected.Canonical.AdvanceCreateAuthority();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1768,8 +1882,18 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
var projection = new RecordingLiveProjectionSink(
|
||||
_ =>
|
||||
{
|
||||
fixture.Runtime.RegisterLiveEntity(
|
||||
Spawn(Generation: 1, PositionSequence: 2));
|
||||
// C3c: a fresher same-generation CreateObject's authority
|
||||
// advance now lands at the residence drain's
|
||||
// WeenieDescription stage; model that advance directly.
|
||||
// C3c-R1 F3: honest MODEL of the executor drain's advance
|
||||
// (ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests); a
|
||||
// nested production OnCreate can no longer reach it —
|
||||
// post-residence registration is description-only
|
||||
// (RuntimeEntityObjectLifetime :660-665,
|
||||
// !beginInitialResidence gate) and ConsumeExecuted already
|
||||
// removed the completed residence entry.
|
||||
fixture.Record.Canonical.AdvanceCreateAuthority();
|
||||
return true;
|
||||
});
|
||||
var publisher = new LiveEntityReadyPublisher(
|
||||
|
|
@ -1792,10 +1916,19 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
WorldEntity entity = record.WorldEntity!;
|
||||
ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion;
|
||||
|
||||
// Models ProjectionPoseReady synchronously accepting a fresher
|
||||
// same-generation CreateObject before EntityReady is emitted.
|
||||
fixture.Runtime.RegisterLiveEntity(
|
||||
Spawn(Generation: 1, PositionSequence: 2));
|
||||
// Models ProjectionPoseReady synchronously observing a fresher
|
||||
// same-generation CreateObject's authority advance before
|
||||
// EntityReady is emitted. C3c: that advance now lands at the
|
||||
// residence drain's WeenieDescription stage
|
||||
// (AdvanceCreateAuthority), not at registration; model it directly.
|
||||
// C3c-R1 F3: honest MODEL of the executor drain's advance
|
||||
// (ApplyWeenieDescriptionAction — the sole production site,
|
||||
// source-pinned by C3cR1F3DriftModelSourcePinTests); a nested
|
||||
// production OnCreate can no longer reach it — post-residence
|
||||
// registration is description-only (RuntimeEntityObjectLifetime
|
||||
// :660-665, !beginInitialResidence gate) and ConsumeExecuted
|
||||
// already removed the completed residence entry.
|
||||
record.Canonical.AdvanceCreateAuthority();
|
||||
bool published = false;
|
||||
|
||||
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
|
||||
|
|
@ -1937,6 +2070,55 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
uint playerGuid = 0u)
|
||||
{
|
||||
Resources = resources ?? new RecordingResources();
|
||||
// C3c: initial-Create registration begins the canonical
|
||||
// residence, whose admission requires a live generation; the
|
||||
// fixture also commits the wire landblock's collision generation
|
||||
// and wires the production first-entry drive pump so each Create
|
||||
// transaction completes its conductor synchronously, exactly
|
||||
// like the composed graphical host.
|
||||
EntityObjects.BindEventContext(
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, 1UL);
|
||||
EntityObjects.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new AcDream.Core.Physics.TerrainSurface(
|
||||
new byte[81], new float[256]),
|
||||
Array.Empty<AcDream.Core.Physics.CellSurface>(),
|
||||
Array.Empty<AcDream.Core.Physics.PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, 1UL, ready: true);
|
||||
Movement = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState();
|
||||
IdentityState = new AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState();
|
||||
var publication = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsPublicationState(
|
||||
EntityObjects.Entities,
|
||||
EntityObjects.Physics,
|
||||
Movement,
|
||||
IdentityState);
|
||||
Movement.AttachPhysicsPublication(publication);
|
||||
EntityObjects.LocalPlayerFirstEntry.BindPublication(publication);
|
||||
IdentityState.ServerGuid = playerGuid;
|
||||
FirstEntry = new AcDream.Runtime.Session.RuntimeFirstEntryDriveController(
|
||||
EntityObjects,
|
||||
new AcDream.Runtime.GameRuntimeClock(),
|
||||
new HydrationNullCollisionSource(),
|
||||
() => AcDream.Runtime.Gameplay.PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f,
|
||||
1.835f,
|
||||
AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// C3c: the real per-session placement subscription — without it
|
||||
// the first entity's unacknowledged ExecutorCompleted receipt
|
||||
// wedges the one ordered FIFO and every later Create's conductor
|
||||
// yields AwaitingReceiptAcknowledgement forever. Ack rules mirror
|
||||
// production: Discard/ExecutorCompleted acknowledge-only;
|
||||
// Place/Withdraw stay at the head for the conductor machinery.
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu,
|
||||
|
|
@ -1947,6 +2129,11 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Resources,
|
||||
Teardown,
|
||||
EntityObjects);
|
||||
_placements = new AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription(
|
||||
EntityObjects.Placements,
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
new FixturePlacementSink(Runtime));
|
||||
Materializer = new RecordingMaterializer(Runtime, Operations);
|
||||
Relationships = new RecordingRelationships(Operations);
|
||||
Ready = new RecordingReadyPublisher(Operations);
|
||||
|
|
@ -1988,7 +2175,90 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Timestamps,
|
||||
identity,
|
||||
deletion,
|
||||
Dormant);
|
||||
Dormant,
|
||||
firstEntry: FirstEntry);
|
||||
}
|
||||
|
||||
public AcDream.Runtime.Session.RuntimeFirstEntryDriveController FirstEntry { get; }
|
||||
private readonly AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription _placements;
|
||||
|
||||
private sealed class FixturePlacementSink(LiveEntityRuntime runtime)
|
||||
: AcDream.Runtime.Physics.IRuntimePlacementProjectionSink
|
||||
{
|
||||
public bool TryApply(
|
||||
in AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot projection)
|
||||
{
|
||||
if (projection.Kind is AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionKind.Discard)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (projection.Kind is AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionKind.ExecutorCompleted)
|
||||
{
|
||||
return projection.Token.ExactCellId == 0u
|
||||
|| runtime.TryApplyInitialCreateCompletionPresentation(
|
||||
in projection);
|
||||
}
|
||||
return !runtime.HasActiveInitialCreateResidence(
|
||||
projection.Token.Entity)
|
||||
&& runtime.TryApplyRuntimePlacementProjection(in projection);
|
||||
}
|
||||
}
|
||||
public AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState Movement { get; }
|
||||
public AcDream.Runtime.Gameplay.RuntimeLocalPlayerIdentityState IdentityState { get; }
|
||||
|
||||
private sealed class HydrationNullCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision>.Loaded(
|
||||
new AcDream.Core.Physics.FlatSetupCollision(
|
||||
System.Collections.Immutable.ImmutableArray<
|
||||
AcDream.Core.Physics.FlatCollisionCylinder>.Empty,
|
||||
[new AcDream.Core.Physics.FlatCollisionSphere(
|
||||
System.Numerics.Vector3.Zero, 0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f));
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public LiveEntityRecord Record
|
||||
|
|
@ -2165,7 +2435,34 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
},
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out LiveEntityRecord? expectedRecord);
|
||||
out LiveEntityRecord? expectedRecord,
|
||||
// C3c: mirror the production materializer
|
||||
// (DatLiveEntityProjectionMaterializer.MaterializeProjection),
|
||||
// which materializes route-1 world creates residence-managed.
|
||||
// The legacy-immediate default would commit the wire cell
|
||||
// out-of-band and retire the fresh residence lease before the
|
||||
// fixture's drive pump ever ran (diagnosed RejectedToken).
|
||||
AcDream.App.World.LiveEntityMaterializationResidence
|
||||
.AwaitRuntimePlacement);
|
||||
// C3c: mirror the production materializer's self-projection
|
||||
// branch — when the residence-driven placement already committed
|
||||
// (or a legacy post-residence path committed the cell) before
|
||||
// this sidecar could exist, its completion receipt is gone, so
|
||||
// presentation self-projects from the committed canonical state
|
||||
// through the presentation-only bucket path.
|
||||
if (entity is not null
|
||||
&& expectedRecord is not null
|
||||
&& runtime.IsCurrentCreateIntegration(
|
||||
expectedCanonical,
|
||||
expectedCreateIntegrationVersion)
|
||||
&& expectedCanonical.FullCellId != 0u
|
||||
&& !runtime.HasActiveInitialCreateResidence(expectedCanonical)
|
||||
&& !runtime.RebucketLiveEntity(
|
||||
canonicalSpawn.Guid,
|
||||
expectedCanonical.FullCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (ThrowAfterMaterializePurposeOnce == purpose)
|
||||
{
|
||||
ThrowAfterMaterializePurposeOnce = null;
|
||||
|
|
|
|||
|
|
@ -423,6 +423,11 @@ public sealed class LiveEntityLifecycleStressTests
|
|||
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
|
||||
|
||||
EntityObjects = new RuntimeEntityObjectLifetime(Engine);
|
||||
// C3c: initial-Create registration begins the canonical residence,
|
||||
// whose admission requires a live session generation.
|
||||
EntityObjects.BindEventContext(
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
Runtime = new LiveEntityRuntime(
|
||||
Spatial,
|
||||
new DelegateLiveEntityResourceLifecycle(
|
||||
|
|
|
|||
|
|
@ -639,6 +639,9 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
teardown);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) =>
|
||||
// C3c: residence admission requires the flattened identity fields to
|
||||
// agree with a nested PhysicsDesc block; a bare logical fixture
|
||||
// carries the minimal consistent one (instance timestamp only).
|
||||
new(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
|
|
@ -652,7 +655,37 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
|
|||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: instance);
|
||||
InstanceSequence: instance,
|
||||
Physics: new PhysicsSpawnData(
|
||||
RawState: 0u,
|
||||
Position: null,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
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: new PhysicsTimestamps(
|
||||
Position: 0,
|
||||
Movement: 0,
|
||||
State: 0,
|
||||
Vector: 0,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 0,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 0,
|
||||
Instance: instance)));
|
||||
|
||||
private static AcDream.Core.World.WorldEntity Entity(
|
||||
uint localId,
|
||||
|
|
|
|||
|
|
@ -733,7 +733,10 @@ public sealed class LiveEntityPresentationControllerTests
|
|||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x01010001u, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
// C3c: residence admission requires the nested block's Instance
|
||||
// timestamp to agree with the flattened InstanceSequence.
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
1, 1, 1, 1, 0, 1, 0, 1, instanceSequence);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)state,
|
||||
Position: position,
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests
|
|||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: instance);
|
||||
// C3c: residence admission requires the flattened parser
|
||||
// projections to agree with a nested PhysicsDesc block.
|
||||
spawn = spawn.WithConsistentPhysics();
|
||||
LiveEntityRecord record = Live.RegisterAndMaterializeProjection(
|
||||
spawn,
|
||||
id => new WorldEntity
|
||||
|
|
|
|||
|
|
@ -826,13 +826,22 @@ public sealed class LiveEntityRuntimeTests
|
|||
{
|
||||
const uint parentGuid = 0x70000020u;
|
||||
const uint childGuid = 0x70000021u;
|
||||
var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources());
|
||||
// C3c: initial residences defer wire applies into their FIFO until a
|
||||
// host pump drives the conductors; use the driven fixture and pump
|
||||
// after each Create so the parent-event tail commits as before.
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources());
|
||||
LiveEntityRuntime runtime = driven.Runtime;
|
||||
runtime.RegisterLiveEntity(Spawn(parentGuid, 9, 1, 0x01010001u));
|
||||
driven.Pump();
|
||||
runtime.ParentAttachments.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 2, 9, 5));
|
||||
ResolveParent(runtime, childGuid);
|
||||
|
||||
runtime.RegisterLiveEntity(Spawn(childGuid, 3, 4, 0x01010001u));
|
||||
driven.Pump();
|
||||
ResolveParent(runtime, childGuid);
|
||||
|
||||
Assert.True(runtime.ParentAttachments.TryGetProjection(
|
||||
|
|
@ -1261,6 +1270,143 @@ public sealed class LiveEntityRuntimeTests
|
|||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review R2: the presentation-only rebucket shortcut is scoped
|
||||
/// to the ACTIVE initial-create residence (where the public API is
|
||||
/// suppressed outright — the conductor's completion receipt is the only
|
||||
/// presentation channel). A RETIRED-residence entity's sticky
|
||||
/// MaterializationResidence must NOT keep it on the shortcut: the
|
||||
/// unflipped legacy update routes (network position/state, teleports,
|
||||
/// streaming reprojection, hydration recovery — all callers of this one
|
||||
/// public RebucketLiveEntity chokepoint) are the position authority
|
||||
/// again, so post-residence moves take the FULL legacy branch:
|
||||
/// CommitRebucket writes the canonical cell and retail's
|
||||
/// prepare_to_enter_world (0x00511FA0) clock rebase runs on every
|
||||
/// root-workset membership edge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PostResidenceRebucket_TakesTheFullLegacyPathIncludingTheClockEdge()
|
||||
{
|
||||
const uint guid = 0x7000004Au;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
spatial.AddLandblock(EmptyLandblock(0x0102FFFFu));
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
spatial,
|
||||
new RecordingResources());
|
||||
LiveEntityRuntime runtime = driven.Runtime;
|
||||
RuntimeEntityRecord canonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 1, 1, 0x01010001u)).Canonical);
|
||||
runtime.MaterializeLiveEntity(
|
||||
canonical,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid),
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out _,
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
|
||||
// ACTIVE residence: the public API stays suppressed (the completion
|
||||
// receipt is the entity's first world-visible moment) and no legacy
|
||||
// cell commit can race the conductor's pending placement.
|
||||
Assert.True(runtime.HasActiveInitialCreateResidence(canonical));
|
||||
Assert.False(runtime.RebucketLiveEntity(guid, 0x01020001u));
|
||||
Assert.Equal(0u, canonical.FullCellId);
|
||||
// C3c-R1 review F5: the tracked-but-undriven entry is visible in
|
||||
// the entity-object ownership ledger while it awaits its pump.
|
||||
Assert.Equal(
|
||||
1,
|
||||
driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount);
|
||||
|
||||
driven.Pump();
|
||||
Assert.False(runtime.HasActiveInitialCreateResidence(canonical));
|
||||
Assert.Equal(
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement,
|
||||
record.MaterializationResidence);
|
||||
Assert.Equal(0x01010001u, canonical.FullCellId);
|
||||
|
||||
// C3c-R1 review F5: the drive's tracked entries fold into the
|
||||
// entity-object ownership ledger — one pending entry while the
|
||||
// residence awaited its pump, zero after.
|
||||
Assert.Equal(
|
||||
0,
|
||||
driven.Lifetime.CaptureOwnership().FirstEntryDrivePendingCount);
|
||||
|
||||
// RETIRED residence, loaded-to-loaded: full legacy branch commits
|
||||
// the canonical cell (the presentation-only shortcut never did) and
|
||||
// preserves the running clock.
|
||||
RetailObjectQuantumClock clock = record.ObjectClock;
|
||||
Assert.Equal(0, clock.Advance(0.02).Count);
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u));
|
||||
Assert.Equal(0x01020001u, canonical.FullCellId);
|
||||
Assert.Same(clock, record.ObjectClock);
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.02, clock.PendingSeconds, 8);
|
||||
|
||||
// Membership edge into a pending bucket suspends the clock; the
|
||||
// pending drain's reentry rebases it for enter-world — the retail
|
||||
// prepare_to_enter_world edge the shortcut skipped.
|
||||
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
|
||||
Assert.Equal(0x02020001u, canonical.FullCellId);
|
||||
Assert.False(clock.IsActive);
|
||||
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||
Assert.True(clock.IsActive);
|
||||
Assert.Equal(0.0, clock.PendingSeconds, 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review F1: converting the sticky residence-managed
|
||||
/// presentation kind to LegacyImmediate (the equipped-child
|
||||
/// world→attached transition) is the owner's explicit API — it refuses
|
||||
/// while the initial-create residence lease is still active, because an
|
||||
/// attached materialization would otherwise race the conductor's
|
||||
/// pending placement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResidenceConversionToLegacyImmediate_RefusesWhileTheResidenceIsActive()
|
||||
{
|
||||
const uint guid = 0x7000004Bu;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
spatial,
|
||||
new RecordingResources());
|
||||
LiveEntityRuntime runtime = driven.Runtime;
|
||||
RuntimeEntityRecord canonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(runtime.RegisterLiveEntity(
|
||||
Spawn(guid, 1, 1, 0x01010001u)).Canonical);
|
||||
runtime.MaterializeLiveEntity(
|
||||
canonical,
|
||||
0x01010001u,
|
||||
id => Entity(id, guid),
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out _,
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
|
||||
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
runtime.ConvertMaterializationResidenceToLegacyImmediate(record));
|
||||
Assert.Equal(
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement,
|
||||
record.MaterializationResidence);
|
||||
|
||||
driven.Pump();
|
||||
runtime.ConvertMaterializationResidenceToLegacyImmediate(record);
|
||||
Assert.Equal(
|
||||
LiveEntityMaterializationResidence.LegacyImmediate,
|
||||
record.MaterializationResidence);
|
||||
// Idempotent once converted (and a no-op for legacy records).
|
||||
runtime.ConvertMaterializationResidenceToLegacyImmediate(record);
|
||||
Assert.Equal(
|
||||
LiveEntityMaterializationResidence.LegacyImmediate,
|
||||
record.MaterializationResidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitiallyVisibleStaticObject_RebasesWithoutBecomingActive()
|
||||
{
|
||||
|
|
@ -1428,10 +1574,27 @@ public sealed class LiveEntityRuntimeTests
|
|||
{
|
||||
const uint stateBeforeBindGuid = 0x70000037u;
|
||||
const uint bindBeforeStateGuid = 0x70000038u;
|
||||
var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources());
|
||||
runtime.RegisterLiveEntity(Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u));
|
||||
runtime.RegisterLiveEntity(Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u));
|
||||
// C3c: initial residences defer wire applies into their FIFO until a
|
||||
// host pump drives the conductors.
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources());
|
||||
LiveEntityRuntime runtime = driven.Runtime;
|
||||
RuntimeEntityRecord stateBeforeBindCanonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(runtime.RegisterLiveEntity(
|
||||
Spawn(stateBeforeBindGuid, 1, 1, 0x01010001u)).Canonical);
|
||||
RuntimeEntityRecord bindBeforeStateCanonical =
|
||||
Assert.IsType<RuntimeEntityRecord>(runtime.RegisterLiveEntity(
|
||||
Spawn(bindBeforeStateGuid, 1, 1, 0x01010001u)).Canonical);
|
||||
|
||||
// C3c/C3b: the first-entry conductor constructs the canonical body
|
||||
// at Create (never-clobber: a fixture can no longer seed a
|
||||
// replacement RemoteMotionRuntime over it). "Arrival order" is now
|
||||
// SetState-before-the-drain (FIFO'd into the pending residence,
|
||||
// applied against the conductor-built body at Execute) versus
|
||||
// SetState-after-completion (legacy immediate apply). Both must
|
||||
// leave the canonical body's state synchronized.
|
||||
PhysicsStateFlags firstState = PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.Gravity
|
||||
| PhysicsStateFlags.ReportCollisions;
|
||||
|
|
@ -1439,18 +1602,30 @@ public sealed class LiveEntityRuntimeTests
|
|||
new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2),
|
||||
out _));
|
||||
runtime.MaterializeLiveEntity(
|
||||
stateBeforeBindGuid,
|
||||
stateBeforeBindCanonical,
|
||||
0x01010001u,
|
||||
id => Entity(id, stateBeforeBindGuid));
|
||||
var lateBody = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody);
|
||||
|
||||
id => Entity(id, stateBeforeBindGuid),
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out _,
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
|
||||
runtime.MaterializeLiveEntity(
|
||||
bindBeforeStateGuid,
|
||||
bindBeforeStateCanonical,
|
||||
0x01010001u,
|
||||
id => Entity(id, bindBeforeStateGuid));
|
||||
var earlyBody = new RemoteMotionRuntime();
|
||||
runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody);
|
||||
id => Entity(id, bindBeforeStateGuid),
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out _,
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
|
||||
driven.Pump();
|
||||
PhysicsBody lateBody = runtime.GetOrCreatePhysicsBody(
|
||||
stateBeforeBindGuid,
|
||||
static _ => throw new InvalidOperationException(
|
||||
"The conductor-built canonical body should already exist."));
|
||||
PhysicsBody earlyBody = runtime.GetOrCreatePhysicsBody(
|
||||
bindBeforeStateGuid,
|
||||
static _ => throw new InvalidOperationException(
|
||||
"The conductor-built canonical body should already exist."));
|
||||
PhysicsStateFlags secondState = PhysicsStateFlags.Static
|
||||
| PhysicsStateFlags.Ethereal
|
||||
| PhysicsStateFlags.NoDraw;
|
||||
|
|
@ -1460,8 +1635,8 @@ public sealed class LiveEntityRuntimeTests
|
|||
|
||||
Assert.Equal((firstState & ~PhysicsStateFlags.ReportCollisions)
|
||||
| PhysicsStateFlags.IgnoreCollisions,
|
||||
lateBody.Body.State);
|
||||
Assert.Equal(secondState, earlyBody.Body.State);
|
||||
lateBody.State);
|
||||
Assert.Equal(secondState, earlyBody.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1573,9 +1748,17 @@ public sealed class LiveEntityRuntimeTests
|
|||
public void PositionAfterPickup_RequiresTeleportHookEvenWithEqualTeleportStamp()
|
||||
{
|
||||
const uint guid = 0x70000043u;
|
||||
var runtime = LiveEntityRuntimeFixture.Create(new GpuWorldState(), new RecordingResources());
|
||||
// C3c: initial residences defer wire applies into their FIFO until a
|
||||
// host pump drives the conductors; use the driven fixture and pump
|
||||
// after the Create so the pickup/position tail flows as before.
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime driven =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
new GpuWorldState(),
|
||||
new RecordingResources());
|
||||
LiveEntityRuntime runtime = driven.Runtime;
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
driven.Pump();
|
||||
Assert.True(runtime.TryApplyPickup(
|
||||
new PickupEvent.Parsed(guid, 1, 2),
|
||||
out _));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,743 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
/// <summary>
|
||||
/// C3c contract integration tests: the flipped graphical host wiring driven
|
||||
/// end-to-end — registration through <see cref="LiveEntityHydrationController"/>,
|
||||
/// the REAL <see cref="RuntimePlacementPresentationSink"/> behind the REAL
|
||||
/// <see cref="AcDream.Runtime.Physics.RuntimePlacementProjectionSubscription"/>,
|
||||
/// and the production
|
||||
/// <see cref="AcDream.Runtime.Session.RuntimeFirstEntryDriveController"/> pump.
|
||||
/// No conductor is ever hand-called.
|
||||
/// </summary>
|
||||
public sealed class RuntimeFirstEntryHostIntegrationTests
|
||||
{
|
||||
private const uint Cell = 0x01010001u;
|
||||
private const uint Guid = 0x70000301u;
|
||||
|
||||
[Fact]
|
||||
public void InitialCreate_ResidenceConductorReceipt_BindsWorldVisibilityExactlyOnce()
|
||||
{
|
||||
using var fixture = new HostFixture(playerGuid: 0u);
|
||||
int residencesBegan = 0;
|
||||
fixture.EntityObjects.BindInitialResidenceBeginNotification(
|
||||
_ => residencesBegan++);
|
||||
bool visibleAtMaterialize = true;
|
||||
fixture.Materializer.AfterMaterialize = record =>
|
||||
{
|
||||
// Clause 1: the sidecar exists but presentation stays suppressed
|
||||
// until the conductor's completion receipt binds it.
|
||||
visibleAtMaterialize = record.IsSpatiallyProjected
|
||||
|| record.IsSpatiallyVisible;
|
||||
};
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(Guid, Cell));
|
||||
|
||||
Assert.Equal(1, residencesBegan);
|
||||
Assert.False(visibleAtMaterialize);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||
// The residence was consumed by the conductor inside the Create
|
||||
// transaction's own pump.
|
||||
Assert.False(fixture.Runtime.HasActiveInitialCreateResidence(
|
||||
record.Canonical));
|
||||
Assert.Equal(Cell, record.Canonical.FullCellId);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.NotNull(record.PhysicsBody);
|
||||
Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges));
|
||||
AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot =
|
||||
Assert.Single(fixture.WorldState.Entities);
|
||||
Assert.Equal(record.WorldEntity!.Position, snapshot.Position);
|
||||
Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount);
|
||||
Assert.Equal(0, fixture.FirstEntry.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredParentCreate_StaysInvisibleUntilParentReplay()
|
||||
{
|
||||
const uint parentGuid = 0x70000302u;
|
||||
const uint childGuid = 0x70000303u;
|
||||
using var fixture = new HostFixture(playerGuid: 0u);
|
||||
int residencesBegan = 0;
|
||||
fixture.EntityObjects.BindInitialResidenceBeginNotification(
|
||||
_ => residencesBegan++);
|
||||
|
||||
fixture.Controller.OnCreate(ParentedSpawn(childGuid, parentGuid));
|
||||
|
||||
// Retail queues the raw blob under the parent's GUID; nothing about
|
||||
// the child may escape — no canonical, no sidecar, no presentation.
|
||||
Assert.Equal(0, residencesBegan);
|
||||
Assert.False(fixture.Runtime.TryGetCanonical(childGuid, out _));
|
||||
Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _));
|
||||
Assert.True(fixture.Runtime.ParentAttachments.ContainsDeferredCreate(
|
||||
childGuid,
|
||||
instanceSequence: 1));
|
||||
Assert.Empty(fixture.WorldState.Entities);
|
||||
Assert.Empty(fixture.VisibilityEdges);
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(parentGuid, Cell));
|
||||
// The replayed child's residence was recorded mid-drain; the next
|
||||
// frame's pump (the per-frame retry phase) drives its conductor.
|
||||
fixture.FirstEntry.DriveAll();
|
||||
|
||||
Assert.Equal(2, residencesBegan);
|
||||
Assert.False(fixture.Runtime.ParentAttachments.ContainsDeferredCreate(
|
||||
childGuid,
|
||||
instanceSequence: 1));
|
||||
Assert.True(fixture.Runtime.TryGetCanonical(
|
||||
childGuid,
|
||||
out RuntimeEntityRecord child));
|
||||
Assert.False(fixture.Runtime.HasActiveInitialCreateResidence(child));
|
||||
Assert.Equal(0, fixture.FirstEntry.PendingCount);
|
||||
// The parented child is celless and presentation-suppressed until its
|
||||
// own attach/position flow — only the parent is world-visible.
|
||||
Assert.Equal(0u, child.FullCellId);
|
||||
Assert.False(fixture.Runtime.TryGetRecord(childGuid, out _));
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
parentGuid,
|
||||
out LiveEntityRecord parent));
|
||||
Assert.True(parent.IsSpatiallyVisible);
|
||||
Assert.Single(fixture.WorldState.Entities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalLogin_PresentationAttachFailure_RetriesWithoutRuntimeRollback()
|
||||
{
|
||||
using var fixture = new HostFixture(playerGuid: Guid);
|
||||
// The camera/shadow-analog App attach failure: the first
|
||||
// world-visibility binding throws AFTER Runtime committed the
|
||||
// controller/body/placement.
|
||||
fixture.VisibilityFailuresRemaining = 1;
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(Guid, Cell));
|
||||
|
||||
// Runtime is NOT rolled back by the App-side presentation failure:
|
||||
// the published movement controller, canonical body, and committed
|
||||
// cell all survive; only the completion receipt stays pending for
|
||||
// the per-frame retry.
|
||||
AcDream.Runtime.Gameplay.PlayerMovementController controller =
|
||||
Assert.IsType<AcDream.Runtime.Gameplay.PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
Assert.True(controller.IsRuntimePublished);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||
Assert.Equal(Cell, record.Canonical.FullCellId);
|
||||
Assert.NotNull(record.PhysicsBody);
|
||||
Assert.False(fixture.Runtime.HasActiveInitialCreateResidence(
|
||||
record.Canonical));
|
||||
Assert.Equal(1, fixture.EntityObjects.Placements.PendingCount);
|
||||
Assert.Equal((record, true), Assert.Single(fixture.VisibilityEdges));
|
||||
|
||||
Assert.True(fixture.Subscription.RetryPending());
|
||||
|
||||
Assert.Same(controller, fixture.Movement.Controller);
|
||||
Assert.True(controller.IsRuntimePublished);
|
||||
Assert.Equal(0, fixture.EntityObjects.Placements.PendingCount);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.True(record.IsSpatiallyVisible);
|
||||
Assert.Equal(2, fixture.VisibilityEdges.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F5: through the REAL flipped host wiring (hydration ->
|
||||
/// residence -> conductor -> publication -> dormant activation), a
|
||||
/// login onto flat ground must complete with retail's
|
||||
/// first-gravity-frame contact (SmartBox::HandleCreateObject 0x00454C80
|
||||
/// -> init_player 0x00455010 -> CPhysicsObj::enter_world 0x00516170 +
|
||||
/// the first simulated frame's touch, compressed via the shared #270
|
||||
/// settle) — and the outbound motion snapshot must report grounded,
|
||||
/// the exact bit LocalPlayerOutboundController serializes and ACE's
|
||||
/// "You can't do that while in the air!" gate reads. Spawn feet at 5
|
||||
/// over a flat floor at 4.7 (0.3 m inside the settle reach), with the
|
||||
/// production human bottom-sphere origin so the authored placement
|
||||
/// stands clear of the floor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LocalLogin_FlatGround_ReportsGroundedOutboundContactBit()
|
||||
{
|
||||
using var fixture = new HostFixture(
|
||||
playerGuid: Guid,
|
||||
terrainHeight: 4.7f,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(Guid, Cell));
|
||||
|
||||
AcDream.Runtime.Gameplay.PlayerMovementController controller =
|
||||
Assert.IsType<AcDream.Runtime.Gameplay.PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
Assert.True(controller.IsRuntimePublished);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
Guid,
|
||||
out LiveEntityRecord record));
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(body.InContact);
|
||||
Assert.True(body.OnWalkable);
|
||||
Assert.True(body.ContactPlaneValid);
|
||||
Assert.InRange(body.Position.Z, 4.65f, 4.76f);
|
||||
Assert.True(controller.CanSendPositionEvent);
|
||||
Assert.True(controller.CaptureMovementResult(
|
||||
mouseLookEvent: false).IsOnGround);
|
||||
Assert.Equal(0, fixture.FirstEntry.PendingCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F5 counterpart through the same real wiring: a login spawn with
|
||||
/// no floor within the settle's reach stays genuinely airborne — no
|
||||
/// forced grounding anywhere in the first-entry sequence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LocalLogin_AirborneSpawn_StaysGenuinelyAirborne()
|
||||
{
|
||||
using var fixture = new HostFixture(
|
||||
playerGuid: Guid,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
|
||||
fixture.Controller.OnCreate(Spawn(Guid, Cell));
|
||||
|
||||
AcDream.Runtime.Gameplay.PlayerMovementController controller =
|
||||
Assert.IsType<AcDream.Runtime.Gameplay.PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
Assert.True(controller.IsRuntimePublished);
|
||||
Assert.True(fixture.Runtime.TryGetRecord(
|
||||
Guid,
|
||||
out LiveEntityRecord record));
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(record.PhysicsBody);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.False(body.InContact);
|
||||
Assert.False(body.OnWalkable);
|
||||
Assert.False(controller.CanSendPositionEvent);
|
||||
Assert.False(controller.CaptureMovementResult(
|
||||
mouseLookEvent: false).IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphicalAndDirectHosts_CommitIdenticalFirstEntryRuntimeFacts()
|
||||
{
|
||||
// Graphical host: full flipped wiring.
|
||||
using var graphical = new HostFixture(playerGuid: 0u);
|
||||
graphical.Controller.OnCreate(Spawn(Guid, Cell));
|
||||
Assert.True(graphical.Runtime.TryGetCanonical(
|
||||
Guid,
|
||||
out RuntimeEntityRecord graphicalRecord));
|
||||
|
||||
// Direct (no-window) host: the same canonical machinery with no
|
||||
// presentation at all — registration, drive pump, ack-only
|
||||
// subscription (the headless host shape).
|
||||
LiveEntityRuntimeFixture.DrivenLiveEntityRuntime direct =
|
||||
LiveEntityRuntimeFixture.CreateDriven(
|
||||
new GpuWorldState(),
|
||||
new NoopResources());
|
||||
RuntimeEntityRecord directRecord = Assert.IsType<RuntimeEntityRecord>(
|
||||
direct.Lifetime.RegisterEntityWithInitialResidence(
|
||||
Spawn(Guid, Cell),
|
||||
isLocalPlayer: false).Canonical);
|
||||
Assert.True(direct.Lifetime.ApplyAcceptedSpawn(
|
||||
directRecord,
|
||||
directRecord.CreateIntegrationVersion,
|
||||
directRecord.Snapshot,
|
||||
replaceGeneration: false));
|
||||
direct.Pump();
|
||||
|
||||
Assert.Equal(
|
||||
FirstEntryFacts.Capture(graphicalRecord),
|
||||
FirstEntryFacts.Capture(directRecord));
|
||||
Assert.False(graphical.Runtime.HasActiveInitialCreateResidence(
|
||||
graphicalRecord));
|
||||
Assert.Equal(0, direct.FirstEntry.PendingCount);
|
||||
Assert.Equal(0, graphical.FirstEntry.PendingCount);
|
||||
}
|
||||
|
||||
private readonly record struct FirstEntryFacts(
|
||||
uint ServerGuid,
|
||||
ushort Incarnation,
|
||||
uint? LocalEntityId,
|
||||
uint FullCellId,
|
||||
uint CanonicalLandblockId,
|
||||
ulong PositionAuthorityVersion,
|
||||
ulong PlacementCommitVersion,
|
||||
ulong CreateIntegrationVersion,
|
||||
ushort SnapshotPositionSequence,
|
||||
bool HasBody,
|
||||
Vector3 BodyPosition,
|
||||
Quaternion BodyOrientation,
|
||||
PhysicsStateFlags BodyState,
|
||||
bool BodyInWorld)
|
||||
{
|
||||
internal static FirstEntryFacts Capture(RuntimeEntityRecord record) =>
|
||||
new(
|
||||
record.ServerGuid,
|
||||
record.Incarnation,
|
||||
record.LocalEntityId,
|
||||
record.FullCellId,
|
||||
record.CanonicalLandblockId,
|
||||
record.PositionAuthorityVersion,
|
||||
record.PlacementCommitVersion,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot.PositionSequence,
|
||||
record.PhysicsBody is not null,
|
||||
record.PhysicsBody?.Position ?? default,
|
||||
record.PhysicsBody?.Orientation ?? default,
|
||||
record.PhysicsBody?.State ?? default,
|
||||
record.PhysicsBody?.InWorld ?? false);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
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,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
"first entry",
|
||||
(uint)ItemType.Creature,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn ParentedSpawn(
|
||||
uint guid,
|
||||
uint parentGuid)
|
||||
{
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: null,
|
||||
Movement: null,
|
||||
AnimationFrame: 1u,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: new PhysicsAttachment(parentGuid, LocationId: 1u),
|
||||
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: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "deferred child",
|
||||
ItemType: (uint)ItemType.Creature,
|
||||
MotionState: null,
|
||||
MotionTableId: 0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
ParentGuid: parentGuid,
|
||||
ParentLocation: 1u,
|
||||
PlacementId: 1u,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private sealed class HostFixture : IDisposable
|
||||
{
|
||||
internal readonly RuntimeEntityObjectLifetime EntityObjects = new();
|
||||
internal readonly LiveEntityRuntime Runtime;
|
||||
internal readonly LiveEntityHydrationController Controller;
|
||||
internal readonly HostMaterializer Materializer;
|
||||
internal readonly AcDream.Runtime.Session.RuntimeFirstEntryDriveController
|
||||
FirstEntry;
|
||||
internal readonly AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription Subscription;
|
||||
internal readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState
|
||||
Movement;
|
||||
internal readonly WorldGameState WorldState = new();
|
||||
internal readonly List<(LiveEntityRecord Record, bool Visible)>
|
||||
VisibilityEdges = [];
|
||||
internal int VisibilityFailuresRemaining;
|
||||
|
||||
internal HostFixture(
|
||||
uint playerGuid,
|
||||
float terrainHeight = 0f,
|
||||
float moverSphereOriginZ = 0f)
|
||||
{
|
||||
EntityObjects.BindEventContext(
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, 1UL);
|
||||
EntityObjects.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new TerrainSurface(
|
||||
new byte[81],
|
||||
Enumerable.Repeat(terrainHeight, 256).ToArray()),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, 1UL, ready: true);
|
||||
Movement = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerMovementState();
|
||||
var runtimeIdentity = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerIdentityState();
|
||||
var publication = new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsPublicationState(
|
||||
EntityObjects.Entities,
|
||||
EntityObjects.Physics,
|
||||
Movement,
|
||||
runtimeIdentity);
|
||||
Movement.AttachPhysicsPublication(publication);
|
||||
EntityObjects.LocalPlayerFirstEntry.BindPublication(publication);
|
||||
runtimeIdentity.ServerGuid = playerGuid;
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
(Cell & 0xFFFF0000u) | 0xFFFFu,
|
||||
new LandBlock(),
|
||||
Array.Empty<WorldEntity>()));
|
||||
Runtime = new LiveEntityRuntime(
|
||||
spatial,
|
||||
new NoopResources(),
|
||||
EntityObjects);
|
||||
FirstEntry = new AcDream.Runtime.Session
|
||||
.RuntimeFirstEntryDriveController(
|
||||
EntityObjects,
|
||||
new AcDream.Runtime.GameRuntimeClock(),
|
||||
new SphereCollisionSource(moverSphereOriginZ),
|
||||
static () => AcDream.Runtime.Gameplay
|
||||
.PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
0.48f,
|
||||
1.835f,
|
||||
AcDream.Runtime.Gameplay
|
||||
.RuntimeLocalPlayerShadowDisposition
|
||||
.ProvenShapeless));
|
||||
var sink = new RuntimePlacementPresentationSink(
|
||||
Runtime,
|
||||
new RuntimeWorldTransitState(),
|
||||
WorldState,
|
||||
new WorldEvents(),
|
||||
new EntityEffectPoseRegistry(),
|
||||
new LocalPlayerShadowState(),
|
||||
() => playerGuid,
|
||||
_ => { },
|
||||
[
|
||||
(record, visible) =>
|
||||
{
|
||||
VisibilityEdges.Add((record, visible));
|
||||
if (VisibilityFailuresRemaining > 0)
|
||||
{
|
||||
VisibilityFailuresRemaining--;
|
||||
throw new InvalidOperationException(
|
||||
"fixture presentation attach failure");
|
||||
}
|
||||
},
|
||||
]);
|
||||
Subscription = new AcDream.Runtime.Physics
|
||||
.RuntimePlacementProjectionSubscription(
|
||||
EntityObjects.Placements,
|
||||
static () => new AcDream.Runtime.RuntimeGenerationToken(1UL),
|
||||
sink);
|
||||
Materializer = new HostMaterializer(Runtime);
|
||||
var identity = new LocalPlayerIdentityState
|
||||
{
|
||||
ServerGuid = playerGuid,
|
||||
};
|
||||
var dormant = new DormantLiveEntityStore();
|
||||
var teardown = new NoopTeardown();
|
||||
var deletion = new LiveEntityDeletionController(
|
||||
Runtime,
|
||||
EntityObjects,
|
||||
teardown,
|
||||
identity,
|
||||
dormant);
|
||||
Controller = new LiveEntityHydrationController(
|
||||
Runtime,
|
||||
EntityObjects,
|
||||
new object(),
|
||||
Materializer,
|
||||
new NoopRelationships(),
|
||||
new AcceptingReady(),
|
||||
new KnownOrigin(),
|
||||
new NoopNetworkSink(),
|
||||
new NoopTimestamps(),
|
||||
identity,
|
||||
deletion,
|
||||
dormant,
|
||||
firstEntry: FirstEntry);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Runtime.Clear();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Failure-path tests assert their own exceptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the production materializer
|
||||
/// (DatLiveEntityProjectionMaterializer.MaterializeProjection): route-1
|
||||
/// world creates materialize residence-managed and self-project only when
|
||||
/// the committed cell already exists with no active residence.
|
||||
/// </summary>
|
||||
private sealed class HostMaterializer(LiveEntityRuntime runtime)
|
||||
: ILiveEntityProjectionMaterializer
|
||||
{
|
||||
internal Action<LiveEntityRecord>? AfterMaterialize { get; set; }
|
||||
|
||||
public bool TryMaterialize(
|
||||
RuntimeEntityRecord expectedCanonical,
|
||||
WorldSession.EntitySpawn canonicalSpawn,
|
||||
LiveProjectionPurpose purpose,
|
||||
ulong expectedCreateIntegrationVersion,
|
||||
AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null)
|
||||
{
|
||||
if (canonicalSpawn.Position is not { } position
|
||||
|| canonicalSpawn.SetupTableId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WorldEntity? entity = runtime.MaterializeLiveEntity(
|
||||
expectedCanonical,
|
||||
position.LandblockId,
|
||||
id => new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = canonicalSpawn.Guid,
|
||||
SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value,
|
||||
Position = new Vector3(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [],
|
||||
ParentCellId = position.LandblockId,
|
||||
},
|
||||
LiveEntityProjectionKind.World,
|
||||
initializeProjection: null,
|
||||
out LiveEntityRecord? record,
|
||||
LiveEntityMaterializationResidence.AwaitRuntimePlacement);
|
||||
if (entity is null || record is null)
|
||||
return false;
|
||||
if (runtime.IsCurrentCreateIntegration(
|
||||
expectedCanonical,
|
||||
expectedCreateIntegrationVersion)
|
||||
&& expectedCanonical.FullCellId != 0u
|
||||
&& !runtime.HasActiveInitialCreateResidence(expectedCanonical)
|
||||
&& !runtime.RebucketLiveEntity(
|
||||
canonicalSpawn.Guid,
|
||||
expectedCanonical.FullCellId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
AfterMaterialize?.Invoke(record);
|
||||
return runtime.IsCurrentRecord(record);
|
||||
}
|
||||
|
||||
public void ResetSessionState()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity)
|
||||
{
|
||||
}
|
||||
|
||||
public void Unregister(WorldEntity entity)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopTeardown : ILiveEntityTeardownCoordinator
|
||||
{
|
||||
public void TearDown(LiveEntityRecord record)
|
||||
{
|
||||
}
|
||||
|
||||
public void ForgetUnknownOwner(uint serverGuid)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopRelationships : ILiveEntityRelationshipProjection
|
||||
{
|
||||
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnParent(ParentEvent.Parsed update)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCreateParentAccepted(CreateParentUpdate update)
|
||||
{
|
||||
}
|
||||
|
||||
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) =>
|
||||
ChildUnparentDisposition.Completed;
|
||||
|
||||
public bool TryApplyAttachedAppearance(
|
||||
LiveEntityRecord record,
|
||||
ulong objDescAuthorityVersion) => false;
|
||||
}
|
||||
|
||||
private sealed class AcceptingReady : ILiveEntityReadyPublisher
|
||||
{
|
||||
public bool Publish(LiveEntityReadyCandidate candidate) => true;
|
||||
}
|
||||
|
||||
private sealed class KnownOrigin : ILiveEntityWorldOriginCoordinator
|
||||
{
|
||||
public bool IsKnown => true;
|
||||
|
||||
public LiveEntityOriginInitialization TryInitialize(
|
||||
WorldSession.EntitySpawn spawn) => new(true, []);
|
||||
}
|
||||
|
||||
private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink
|
||||
{
|
||||
public void ApplySameGeneration(SameGenerationCreateObjectEvents events)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopTimestamps : IAcceptedLocalPhysicsTimestampPublisher
|
||||
{
|
||||
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SphereCollisionSource(float sphereOriginZ = 0f)
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<FlatSetupCollision>
|
||||
.Loaded(new FlatSetupCollision(
|
||||
System.Collections.Immutable.ImmutableArray<
|
||||
FlatCollisionCylinder>.Empty,
|
||||
[new FlatCollisionSphere(
|
||||
new Vector3(0f, 0f, sphereOriginZ),
|
||||
0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f));
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatCellStructureCollisionAsset> ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -757,6 +757,16 @@ public sealed class RuntimePlacementPresentationSinkTests
|
|||
internal LiveEntityRecord Materialize(WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection(spawn);
|
||||
// C3c fixture normalization: RegisterAndMaterializeProjection's
|
||||
// legacy-immediate rebucket commits the wire cell out-of-band of
|
||||
// the fresh initial-create residence, leaving that residence
|
||||
// stale. Converge it deterministically HERE (the query performs
|
||||
// the lazy retirement, releasing the residence's never-driven
|
||||
// initial SetPosition operation) so ownership snapshots captured
|
||||
// by tests reflect the settled post-registration state instead of
|
||||
// shifting inside the sink's own first residence-gate query.
|
||||
Assert.False(Runtime.HasActiveInitialCreateResidence(
|
||||
record.Canonical));
|
||||
Assert.True(record.ResourcesRegistered);
|
||||
WorldEntity entity = record.WorldEntity!;
|
||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
|
|
|
|||
|
|
@ -740,16 +740,22 @@ public sealed class UpdateFrameOrchestratorTests
|
|||
"AcDream.App",
|
||||
"Input",
|
||||
"PlayerModeController.cs"));
|
||||
// C3c (clause 4 — sealed-setter lifecycle routing): the movement
|
||||
// controller, physics body, host, and committed placement are
|
||||
// Runtime-owned, published by the first-entry conductor's
|
||||
// publication transaction. Player-mode entry attaches presentation
|
||||
// only: it gates on the Runtime-published controller, then wires
|
||||
// camera -> shadow -> host slot -> mode flag. The old pinned
|
||||
// markers (PreparePositionForCommit / InstallOrRebind /
|
||||
// CommitPreparedPosition / `_controllerSlot.Controller =`) were
|
||||
// exactly the App-side controller construction+commit this flip
|
||||
// deleted.
|
||||
AssertAppearsInOrder(
|
||||
playerModeSource,
|
||||
"controller.PreparePositionForCommit(",
|
||||
"controller.IsRuntimePublished",
|
||||
"_camera.EnterChaseMode(legacyCamera, retailCamera);",
|
||||
"EntityPhysicsHostComposition.SelectStableHostWithoutRebind(",
|
||||
"_shadow.SyncPose(",
|
||||
"EntityPhysicsHostComposition.InstallOrRebind(",
|
||||
"playerEntity.SetPosition(initial.Position);",
|
||||
"controller.CommitPreparedPosition();",
|
||||
"_controllerSlot.Controller = controller;",
|
||||
"_hostSlot.Host = playerHost;",
|
||||
"_mode.IsPlayerMode = true;");
|
||||
Assert.Contains("_shadow.Restore(playerEntity, priorShadow);", playerModeSource,
|
||||
StringComparison.Ordinal);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,29 @@ public class ShadowObjectRegistryTests
|
|||
Assert.Equal(1, reg.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_CornerLandblock_DerivesRealOutdoorSeed()
|
||||
{
|
||||
// C3c-F3: landblock (0,0) — id 0x0000FFFF — has prefix 0x00000000.
|
||||
// DeriveOutdoorSeed's prefix-0 "absent" sentinel used to reject it,
|
||||
// silently dropping every landblock-baked static in the map-corner
|
||||
// block. Local (12,12) = cell (0,0) = cellId 0x00000000 | 1.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0x0000FFFFu);
|
||||
Assert.Equal(1, reg.TotalRegistered);
|
||||
Assert.Contains(reg.GetObjectsInCell(0x00000001u), e => e.EntityId == 1u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AbsentLandblockId_StillKeepsWhenEmpty()
|
||||
{
|
||||
// The genuine "no landblock" input (id 0) must keep the
|
||||
// keep-when-empty behavior (retail pc:283540) the sentinel provided.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, 0u);
|
||||
Assert.Equal(0, reg.TotalRegistered);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetObjectsInCell
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class RemoteSpawnPlacementSettlerTests
|
||||
/// <summary>
|
||||
/// C3c-F5: moved from tests/AcDream.App.Tests/Physics/
|
||||
/// RemoteSpawnPlacementSettlerTests.cs when the #270 settler moved to Core
|
||||
/// (App -> <see cref="SpawnPlacementSettler"/>) so the local player's
|
||||
/// Runtime first-entry activation can share it. Test bodies unchanged.
|
||||
/// </summary>
|
||||
public sealed class SpawnPlacementSettlerTests
|
||||
{
|
||||
private const uint Landblock = 0xA9B40000u;
|
||||
private const uint Cell = Landblock | 0x0001u;
|
||||
|
|
@ -19,7 +24,7 @@ public sealed class RemoteSpawnPlacementSettlerTests
|
|||
int hitGround = 0;
|
||||
int leaveGround = 0;
|
||||
|
||||
bool settled = RemoteSpawnPlacementSettler.TrySettle(
|
||||
bool settled = SpawnPlacementSettler.TrySettle(
|
||||
engine,
|
||||
body,
|
||||
body.Position,
|
||||
|
|
@ -71,7 +76,7 @@ public sealed class RemoteSpawnPlacementSettlerTests
|
|||
}
|
||||
|
||||
private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) =>
|
||||
RemoteSpawnPlacementSettler.TrySettle(
|
||||
SpawnPlacementSettler.TrySettle(
|
||||
engine,
|
||||
body,
|
||||
body.Position,
|
||||
|
|
@ -238,11 +238,25 @@ public sealed class HeadlessSessionHostTests
|
|||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
// C3c: conductor-driven flow — a live generation admits the initial
|
||||
// residence, the flat landblock's collision generation commits, and
|
||||
// the world projection's pump drives the local first-entry conductor
|
||||
// to completion (the deleted SynchronizeLocalPlayer hand-copy's
|
||||
// replacement).
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000002u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
|
|
@ -252,7 +266,8 @@ public sealed class HeadlessSessionHostTests
|
|||
var collision = new FixtureCollisionNeighborhood();
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision);
|
||||
collision,
|
||||
firstEntry);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
|
|
@ -310,11 +325,25 @@ public sealed class HeadlessSessionHostTests
|
|||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
// C3c: conductor-driven flow — a live generation admits the initial
|
||||
// residence, the flat landblock's collision generation commits, and
|
||||
// the world projection's pump drives the local first-entry conductor
|
||||
// to completion (the deleted SynchronizeLocalPlayer hand-copy's
|
||||
// replacement).
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000003u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
|
|
@ -324,7 +353,8 @@ public sealed class HeadlessSessionHostTests
|
|||
var collision = new FixtureCollisionNeighborhood();
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision);
|
||||
collision,
|
||||
firstEntry);
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
|
|
@ -376,6 +406,104 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.Equal(2, collision.CenterCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review F7: a remote Create whose landblock lies outside the
|
||||
/// bounded collision neighborhood's service window can never see its
|
||||
/// deferred placement's collision-generation wake — left alone it would
|
||||
/// pin its residence (and the drive's pending entry) for the whole
|
||||
/// session. The host converts it to the celless completion route before
|
||||
/// the pump: the residence completes with FullCell 0, the accepted wire
|
||||
/// frame stays on the canonical snapshot (the exact pre-flip
|
||||
/// accepted-frame behavior for far remotes), and every ledger converges.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarRemoteCreateCompletesCelllessWithoutPinningItsResidence()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x5000000Bu;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry);
|
||||
RuntimeEntityRecord playerRecord = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
playerRecord,
|
||||
playerRecord.CreateIntegrationVersion,
|
||||
playerRecord.Snapshot,
|
||||
replaceGeneration: false));
|
||||
projection.ProjectSpawn(playerRecord, isLocalPlayer: true);
|
||||
|
||||
const uint farRemote = 0x70000010u;
|
||||
const uint farCell = 0x00010001u;
|
||||
Assert.False(collision.IsWithinServiceWindow(farCell));
|
||||
RuntimeEntityRecord remote = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(
|
||||
Spawn(farRemote, farCell),
|
||||
isLocalPlayer: false)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
remote,
|
||||
remote.CreateIntegrationVersion,
|
||||
remote.Snapshot,
|
||||
replaceGeneration: false));
|
||||
|
||||
projection.ProjectSpawn(remote, isLocalPlayer: false);
|
||||
|
||||
// Celless completion: no pinned residence, FullCell stays 0, the
|
||||
// accepted wire frame survives on the canonical snapshot.
|
||||
Assert.False(runtime.EntityObjects.TryGetInitialCreateResidence(
|
||||
remote,
|
||||
out _));
|
||||
Assert.Equal(0u, remote.FullCellId);
|
||||
Assert.Equal(
|
||||
farCell,
|
||||
remote.Snapshot.Position!.Value.LandblockId);
|
||||
RuntimeEntityObjectOwnershipSnapshot ownership =
|
||||
runtime.EntityObjects.CaptureOwnership();
|
||||
Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount);
|
||||
Assert.Equal(0, ownership.FirstEntryDrivePendingCount);
|
||||
Assert.Equal(0, firstEntry.PendingCount);
|
||||
|
||||
// Draining the placement FIFO the way the host subscription would
|
||||
// converges the completion-receipt ledger too.
|
||||
while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!runtime.EntityObjects.Physics.SetPosition
|
||||
.AcknowledgeProjection(head.Token))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.CaptureOwnership()
|
||||
.PendingCompletionReceiptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority()
|
||||
{
|
||||
|
|
@ -389,11 +517,25 @@ public sealed class HeadlessSessionHostTests
|
|||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
// C3c: conductor-driven flow — a live generation admits the initial
|
||||
// residence, the flat landblock's collision generation commits, and
|
||||
// the world projection's pump drives the local first-entry conductor
|
||||
// to completion (the deleted SynchronizeLocalPlayer hand-copy's
|
||||
// replacement).
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000004u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry =
|
||||
CreateFirstEntryDrive(runtime);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
|
|
@ -402,7 +544,8 @@ public sealed class HeadlessSessionHostTests
|
|||
replaceGeneration: false));
|
||||
var directProjection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
new FixtureCollisionNeighborhood());
|
||||
new FixtureCollisionNeighborhood(),
|
||||
firstEntry);
|
||||
directProjection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller = Assert.IsType<
|
||||
PlayerMovementController>(runtime.MovementOwner.Controller);
|
||||
|
|
@ -1010,6 +1153,197 @@ public sealed class HeadlessSessionHostTests
|
|||
|
||||
private sealed class FixtureCollisionPublicationException : Exception;
|
||||
|
||||
[Fact]
|
||||
public void MissingPreparedCollisionYieldsTypedRetryAndCompletesWhenAvailable()
|
||||
{
|
||||
// C3c contract: the prepared-collision read failure is the
|
||||
// conductor's typed AwaitingCollisionSource retry — no
|
||||
// InvalidDataException (or any exception) escapes the host wiring,
|
||||
// the entity stays a tracked, re-drivable first entry, and the same
|
||||
// sequence completes once the source can serve the Setup.
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
const uint player = 0x50000021u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
0xA9B40000u, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
0xA9B40000u, 1UL, ready: true);
|
||||
var source = new FlakySetupCollisionSource();
|
||||
var firstEntry = new AcDream.Runtime.Session
|
||||
.RuntimeFirstEntryDriveController(
|
||||
runtime.EntityObjects,
|
||||
runtime.Clock,
|
||||
source,
|
||||
() => PlayerMovementConstructionOptions.From(
|
||||
runtime.CharacterOwner.MovementSkills.Snapshot),
|
||||
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true)
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var collision = new FixtureCollisionNeighborhood();
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
collision,
|
||||
firstEntry);
|
||||
|
||||
projection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
|
||||
Assert.True(source.SetupReadAttempts >= 1);
|
||||
Assert.Null(runtime.MovementOwner.Controller);
|
||||
Assert.Equal(1, firstEntry.PendingCount);
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
|
||||
source.Available = true;
|
||||
// The session tick's retry pump.
|
||||
firstEntry.DriveAll();
|
||||
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
runtime.MovementOwner.Controller);
|
||||
Assert.Equal(0, firstEntry.PendingCount);
|
||||
Assert.Equal(0xA9B40000u, record.FullCellId & 0xFFFF0000u);
|
||||
Assert.NotEqual(0u, record.FullCellId);
|
||||
}
|
||||
|
||||
private sealed class FlakySetupCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
internal bool Available { get; set; }
|
||||
internal int SetupReadAttempts { get; private set; }
|
||||
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SetupReadAttempts++;
|
||||
if (!Available)
|
||||
{
|
||||
return AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatSetupCollision>.Missing;
|
||||
}
|
||||
return AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatSetupCollision>.Loaded(new FlatSetupCollision(
|
||||
System.Collections.Immutable.ImmutableArray<
|
||||
FlatCollisionCylinder>.Empty,
|
||||
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f));
|
||||
}
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatCellStructureCollisionAsset> ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static AcDream.Runtime.Session.RuntimeFirstEntryDriveController
|
||||
CreateFirstEntryDrive(GameRuntime runtime) => new(
|
||||
runtime.EntityObjects,
|
||||
runtime.Clock,
|
||||
new LoadedSetupCollisionSource(),
|
||||
() => PlayerMovementConstructionOptions.From(
|
||||
runtime.CharacterOwner.MovementSkills.Snapshot),
|
||||
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
|
||||
private sealed class LoadedSetupCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<FlatSetupCollision>
|
||||
.Loaded(new FlatSetupCollision(
|
||||
System.Collections.Immutable.ImmutableArray<
|
||||
FlatCollisionCylinder>.Empty,
|
||||
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f));
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
FlatCellStructureCollisionAsset> ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddFlatLandblock(PhysicsEngine engine)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
|
|
@ -1026,10 +1360,12 @@ public sealed class HeadlessSessionHostTests
|
|||
worldOffsetY: 0f);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid)
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
uint cellId = 0xA9B40001u)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0xA9B40001u,
|
||||
cellId,
|
||||
96f,
|
||||
97f,
|
||||
50f,
|
||||
|
|
@ -1217,6 +1553,22 @@ public sealed class HeadlessSessionHostTests
|
|||
|
||||
public bool IsReady(uint fullCellId) =>
|
||||
fullCellId == LastCell;
|
||||
|
||||
// C3c-R1 review F7: the fixture window mirrors production's 3x3
|
||||
// membership around the last requested center; no center yet means
|
||||
// "within" (never convert before the first CenterOn).
|
||||
public bool IsWithinServiceWindow(uint fullCellId)
|
||||
{
|
||||
if (LastCell == 0u)
|
||||
return true;
|
||||
int dx = Math.Abs(
|
||||
(int)((fullCellId >> 24) & 0xFFu)
|
||||
- (int)((LastCell >> 24) & 0xFFu));
|
||||
int dy = Math.Abs(
|
||||
(int)((fullCellId >> 16) & 0xFFu)
|
||||
- (int)((LastCell >> 16) & 0xFFu));
|
||||
return dx <= 1 && dy <= 1;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureEventRoute(
|
||||
|
|
|
|||
|
|
@ -104,18 +104,13 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
|
|||
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second);
|
||||
Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount);
|
||||
|
||||
const ulong generation = 1UL;
|
||||
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation);
|
||||
fixture.Lifetime.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation, ready: true);
|
||||
// C3c-F2: the wake goes through the SAME owner production uses (the
|
||||
// collision admission ledger). Driving the SetPosition seam directly
|
||||
// leaves that ledger empty — a state production can never be in, and
|
||||
// the reason this very test did not catch the login activation wedge.
|
||||
CommitProductionCollisionGeneration(
|
||||
fixture.Lifetime.Physics,
|
||||
Cell & 0xFFFF0000u);
|
||||
|
||||
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed,
|
||||
fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt));
|
||||
|
|
@ -603,18 +598,9 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
|
|||
// AwaitingActivation retry test does.
|
||||
Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation,
|
||||
fixture.Advance(out _));
|
||||
const ulong generation = 1UL;
|
||||
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation);
|
||||
fixture.Lifetime.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation, ready: true);
|
||||
CommitProductionCollisionGeneration(
|
||||
fixture.Lifetime.Physics,
|
||||
Cell & 0xFFFF0000u);
|
||||
|
||||
RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(
|
||||
out RuntimeInitialCreateExecutionReceipt receipt);
|
||||
|
|
@ -631,6 +617,63 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F2: publishes one landblock collision generation through the exact
|
||||
/// production owner chain — BeginCollisionAdmission -> prepare -> stage ->
|
||||
/// CommitCollisionGeneration — so a parked local-player activation wakes
|
||||
/// the way it does live, with RuntimePhysicsState's admission ledger
|
||||
/// populated and then retired.
|
||||
/// </summary>
|
||||
private static void CommitProductionCollisionGeneration(
|
||||
RuntimePhysicsState physics,
|
||||
uint landblockId)
|
||||
{
|
||||
RuntimeCollisionAdmission admission =
|
||||
physics.BeginCollisionAdmission(landblockId);
|
||||
using PreparedLandblockCollisionGeneration prepared =
|
||||
physics.PrepareCollisionGeneration(admission);
|
||||
physics.StageCollisionAssets(
|
||||
admission,
|
||||
prepared,
|
||||
new RuntimeLandblockCollisionAssets(
|
||||
landblockId,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f,
|
||||
0u));
|
||||
for (int poll = 0; poll < 10_000; poll++)
|
||||
{
|
||||
while (physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot projection))
|
||||
{
|
||||
Assert.True(physics.SetPosition.AcknowledgeProjection(
|
||||
projection.Token));
|
||||
}
|
||||
while (!physics.AdvanceCollisionRetainedOwnerCapture(
|
||||
admission,
|
||||
prepared).Completed)
|
||||
{
|
||||
}
|
||||
foreach (uint ownerId in prepared.RetainedOwnerIds)
|
||||
physics.RefreshCollisionRetainedOwner(admission, prepared, ownerId);
|
||||
RuntimeCollisionSealStep seal;
|
||||
do
|
||||
{
|
||||
seal = physics.AdvanceCollisionGenerationSeal(admission, prepared);
|
||||
}
|
||||
while (!seal.Completed && !seal.Restarted);
|
||||
if (!seal.Completed)
|
||||
continue;
|
||||
if (physics.CommitCollisionGeneration(admission, prepared).Completed)
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Collision generation did not complete its Runtime mutation transaction.");
|
||||
}
|
||||
|
||||
private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token)
|
||||
BeginPendingOrdinaryPlacement(Fixture fixture, uint guid)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -244,4 +244,261 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
|||
|
||||
Assert.Equal(0L, allocated);
|
||||
}
|
||||
|
||||
// ── C3c-F1 (2026-08-02): movement-stats application seam ──────────────
|
||||
//
|
||||
// The typed seam is the ONLY route by which server stat recomputes
|
||||
// reach the controller; the publication lifecycle decides whether the
|
||||
// write lands (live/dormant) or is a typed displaced drop (terminal).
|
||||
|
||||
private static RuntimeMovementSkillState CompleteSkillState()
|
||||
{
|
||||
var skills = new RuntimeMovementSkillState();
|
||||
skills.Update(runSkill: 240, jumpSkill: 180);
|
||||
skills.UpdateBurden(0.5f);
|
||||
skills.UpdateStamina(37);
|
||||
// BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) → ObjectInfoState.IsPK.
|
||||
skills.UpdateOwnPwdBitfield(0x28u);
|
||||
skills.UpdatePlayerKillerStatus(1, 123.5f);
|
||||
return skills;
|
||||
}
|
||||
|
||||
private static PlayerMovementController NewDormantRuntimeController()
|
||||
{
|
||||
PlayerMovementController controller =
|
||||
PlayerMovementController.CreatePublicationCandidate(
|
||||
new PhysicsEngine(),
|
||||
PlayerMovementConstructionOptions.Fallback);
|
||||
controller.SealPublicationCandidate();
|
||||
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static (float RunRate, float JumpVz, bool CanJump, int JumpCost,
|
||||
ObjectInfoState PvpFlags) CaptureStatObservables(
|
||||
PlayerMovementController controller)
|
||||
{
|
||||
IWeenieObject weenie = controller.Motion.WeenieObj!;
|
||||
Assert.True(weenie.InqRunRate(out float runRate));
|
||||
Assert.True(weenie.InqJumpVelocity(1.0f, out float jumpVz));
|
||||
bool canJump = weenie.CanJump(1.0f);
|
||||
Assert.True(((PlayerWeenie)weenie).JumpStaminaCost(1.0f, out int cost));
|
||||
return (runRate, jumpVz, canJump, cost, controller.OwnPvpFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_LiveApplicationIsByteIdenticalToTheRetiredDirectPath()
|
||||
{
|
||||
RuntimeMovementSkillState skills = CompleteSkillState();
|
||||
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
|
||||
|
||||
// Old direct path (the deleted RuntimeMovementSkillProjection.ApplyTo
|
||||
// body), applied through the public gated setters.
|
||||
var direct = new PlayerMovementController(new PhysicsEngine());
|
||||
direct.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
|
||||
direct.SetCharacterBurden(snapshot.Burden);
|
||||
direct.SetCharacterStamina(snapshot.CurrentStamina);
|
||||
direct.OwnPvpFlags = EntityCollisionFlagsExt
|
||||
.FromPwdBitfield(snapshot.OwnPwdBitfield)
|
||||
.ToMoverState();
|
||||
direct.SetCharacterPkStatus(
|
||||
snapshot.PlayerKillerStatus,
|
||||
snapshot.LastPkAttackTimestamp);
|
||||
|
||||
var routed = new PlayerMovementController(new PhysicsEngine());
|
||||
using var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = routed,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.AppliedLive,
|
||||
movement.ApplyCharacterMovementStats(skills));
|
||||
Assert.Equal(
|
||||
CaptureStatObservables(direct),
|
||||
CaptureStatObservables(routed));
|
||||
Assert.Equal(ObjectInfoState.IsPK, routed.OwnPvpFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_DormantWindowWriteLandsOnTheControllerThatGoesLive()
|
||||
{
|
||||
RuntimeMovementSkillState skills = CompleteSkillState();
|
||||
PlayerMovementController controller = NewDormantRuntimeController();
|
||||
using var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = controller,
|
||||
};
|
||||
|
||||
// The committed-but-unactivated first-entry window: the dormant
|
||||
// controller is installed in the movement owner while activation
|
||||
// waits on cell streaming, and inbound recomputes keep pumping.
|
||||
Assert.True(controller.IsRuntimeOwnedDormant);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.SetCharacterSkills(1, 1));
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.AppliedDormant,
|
||||
movement.ApplyCharacterMovementStats(skills));
|
||||
|
||||
// Activation flips the SAME instance live — the applied values are
|
||||
// already current when movement starts.
|
||||
controller.ActivateRuntimePublication();
|
||||
Assert.True(controller.IsRuntimePublished);
|
||||
(float runRate, _, _, _, ObjectInfoState pvp) =
|
||||
CaptureStatObservables(controller);
|
||||
Assert.Equal(
|
||||
PlayerWeenie.GetRunRate(0.5f, 240),
|
||||
runRate,
|
||||
precision: 5);
|
||||
Assert.Equal(ObjectInfoState.IsPK, pvp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_TerminalControllerReportsTypedDisplacedDrop()
|
||||
{
|
||||
RuntimeMovementSkillState skills = CompleteSkillState();
|
||||
PlayerMovementController controller = NewDormantRuntimeController();
|
||||
using var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = controller,
|
||||
};
|
||||
controller.ActivateRuntimePublication();
|
||||
var before = CaptureStatObservables(controller);
|
||||
// The Motion surface is gated once terminal; capture the weenie
|
||||
// reference while the controller is still published.
|
||||
IWeenieObject weenie = controller.Motion.WeenieObj!;
|
||||
|
||||
// Post-teardown transient truth: the controller retired while still
|
||||
// reachable through a displaced recompute callback. The public
|
||||
// setter still throws (the seal working); the seam reports the
|
||||
// typed drop instead and mutates nothing.
|
||||
controller.RetireRuntimePublication();
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.SetCharacterSkills(1, 1));
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedDisplacedController,
|
||||
movement.ApplyCharacterMovementStats(skills));
|
||||
|
||||
Assert.True(weenie.InqRunRate(out float runRate));
|
||||
Assert.Equal(before.RunRate, runRate);
|
||||
Assert.Equal(before.PvpFlags, controller.OwnPvpFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_SealedCandidateIsATypedDisplacedDrop()
|
||||
{
|
||||
// Defensive lifecycle-matrix row: a sealed candidate is frozen for
|
||||
// the Prepare→Commit validation handshake and never installed into
|
||||
// the movement owner in production (Prepare requires the owner
|
||||
// empty; Commit installs it already-dormant within the same
|
||||
// synchronous conductor step), so only a direct controller-level
|
||||
// call can observe this row.
|
||||
PlayerMovementController controller =
|
||||
PlayerMovementController.CreatePublicationCandidate(
|
||||
new PhysicsEngine(),
|
||||
PlayerMovementConstructionOptions.Fallback);
|
||||
controller.SealPublicationCandidate();
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedDisplacedController,
|
||||
controller.ApplyCharacterMovementStats(
|
||||
CompleteSkillState().Snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_AbsentControllerAndIncompleteSnapshotDropSilently()
|
||||
{
|
||||
var skills = new RuntimeMovementSkillState();
|
||||
using var movement = new RuntimeLocalPlayerMovementState();
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedNoController,
|
||||
movement.ApplyCharacterMovementStats(skills));
|
||||
|
||||
movement.Controller = new PlayerMovementController(new PhysicsEngine());
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedIncompleteSnapshot,
|
||||
movement.ApplyCharacterMovementStats(skills));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCharacterMovementStats_DisposedOwnerToleratesTheDisplacedCallback()
|
||||
{
|
||||
var movement = new RuntimeLocalPlayerMovementState
|
||||
{
|
||||
Controller = new PlayerMovementController(new PhysicsEngine()),
|
||||
};
|
||||
movement.Dispose();
|
||||
|
||||
// The displaced-callback-rejection pattern: a recompute landing
|
||||
// after terminal disposal observes a typed drop, never a fault.
|
||||
Assert.Equal(
|
||||
RuntimeMovementStatsApplication.DroppedNoController,
|
||||
movement.ApplyCharacterMovementStats(CompleteSkillState()));
|
||||
Assert.False(movement.ReportExhaustion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportExhaustion_DispatchesOnlyThroughALiveController()
|
||||
{
|
||||
using var movement = new RuntimeLocalPlayerMovementState();
|
||||
Assert.False(movement.ReportExhaustion());
|
||||
|
||||
PlayerMovementController controller = NewDormantRuntimeController();
|
||||
movement.Controller = controller;
|
||||
Assert.False(movement.ReportExhaustion());
|
||||
|
||||
controller.ActivateRuntimePublication();
|
||||
Assert.True(movement.ReportExhaustion());
|
||||
|
||||
controller.RetireRuntimePublication();
|
||||
Assert.False(movement.ReportExhaustion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly()
|
||||
{
|
||||
// The second connected-gate crash chain
|
||||
// (logs/connected-world-gate-20260802-125907): an inbound local-
|
||||
// player SetState pushed ApplyPhysicsState at the dormant
|
||||
// first-entry controller. The typed entry decides by lifecycle.
|
||||
PlayerMovementController controller =
|
||||
PlayerMovementController.CreatePublicationCandidate(
|
||||
new PhysicsEngine(),
|
||||
PlayerMovementConstructionOptions.Fallback);
|
||||
PhysicsBody body = controller.PhysicsBody;
|
||||
PhysicsStateFlags initial = body.State;
|
||||
PhysicsStateFlags pushed =
|
||||
PhysicsStateFlags.Gravity
|
||||
| PhysicsStateFlags.ReportCollisions
|
||||
| PhysicsStateFlags.Ethereal;
|
||||
Assert.NotEqual(initial, pushed);
|
||||
|
||||
controller.SealPublicationCandidate();
|
||||
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
|
||||
|
||||
// Dormant: the activation transaction owns the dormant body's
|
||||
// physics state; the push is dropped and the body is untouched.
|
||||
Assert.Equal(
|
||||
RuntimeServerPhysicsStateApplication.DroppedDormantActivationOwned,
|
||||
controller.ApplyServerPhysicsState(pushed));
|
||||
Assert.Equal(initial, body.State);
|
||||
|
||||
// Published: byte-identical to the direct ApplyPhysicsState body.
|
||||
controller.ActivateRuntimePublication();
|
||||
Assert.Equal(
|
||||
RuntimeServerPhysicsStateApplication.AppliedLive,
|
||||
controller.ApplyServerPhysicsState(pushed));
|
||||
Assert.Equal(pushed, body.State);
|
||||
|
||||
// Terminal: a displaced push, typed instead of a fault, mutating
|
||||
// nothing.
|
||||
controller.RetireRuntimePublication();
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => controller.ApplyPhysicsState(initial));
|
||||
Assert.Equal(
|
||||
RuntimeServerPhysicsStateApplication.DroppedDisplacedController,
|
||||
controller.ApplyServerPhysicsState(initial));
|
||||
Assert.Equal(pushed, body.State);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,175 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
Assert.True(body.InWorld);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F5: retail seeds the local player's ground contact from the first
|
||||
/// gravity frame after <c>enter_world</c> (SmartBox::HandleCreateObject
|
||||
/// 0x00454C80 → init_player 0x00455010 → CPhysicsObj::enter_world
|
||||
/// 0x00516170; SetPosition records no touch), which the activation
|
||||
/// compresses through the shared #270 settle sweep. Spawn (1,2,3) with a
|
||||
/// flat floor at 2.7 — feet 0.3 m above it, inside the 0.5 m settle
|
||||
/// reach — and the production human bottom-sphere origin (0.475) so the
|
||||
/// authored placement itself stands clear of the floor.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommitActivationOnFlatGroundSeedsRetailFirstGravityFrameContact()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
residentWorld: true,
|
||||
terrainHeight: 2.7f,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var evaluation));
|
||||
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(fixture.Record.PhysicsBody);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.True(body.InContact);
|
||||
Assert.True(body.OnWalkable);
|
||||
Assert.True(body.ContactPlaneValid);
|
||||
Assert.True(body.ContactPlane.Normal.Z > 0.9f);
|
||||
// The settle snapped the feet onto the floor, exactly like retail's
|
||||
// first gravity frame.
|
||||
Assert.InRange(body.Position.Z, 2.65f, 2.76f);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
// The exact outbound predicate LocalPlayerOutboundController
|
||||
// serializes as the wire contact bit (InContact && OnWalkable).
|
||||
Assert.True(controller.CanSendPositionEvent);
|
||||
Assert.True(controller.CaptureMovementResult(
|
||||
mouseLookEvent: false).IsOnGround);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F5 counterpart: a spawn with no floor within the settle's reach
|
||||
/// (flat terrain at 0, feet at 3) must stay genuinely airborne — the
|
||||
/// seed only ever commits contact the sweep actually found, exactly
|
||||
/// like retail's fall after enter_world.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommitActivationOverVoidLeavesFirstEntryGenuinelyAirborne()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
residentWorld: true,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var evaluation));
|
||||
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
|
||||
PhysicsBody body = Assert.IsType<PhysicsBody>(fixture.Record.PhysicsBody);
|
||||
Assert.True(body.InWorld);
|
||||
Assert.False(body.InContact);
|
||||
Assert.False(body.OnWalkable);
|
||||
Assert.False(body.ContactPlaneValid);
|
||||
Assert.Equal(3f, body.Position.Z);
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
Assert.False(controller.CanSendPositionEvent);
|
||||
Assert.False(controller.CaptureMovementResult(
|
||||
mouseLookEvent: false).IsOnGround);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review R1: the login constraint leash. Retail arms the leash
|
||||
/// at every accepted-position event (SmartBox::HandleReceivedPosition
|
||||
/// 0x00453FD0); the flip deleted the App-side login arm
|
||||
/// (CommitPreparedPosition), so the dormant activation's final commit
|
||||
/// must arm it — anchored at the committed placement (already
|
||||
/// floor-snapped by the faithful placement transaction), before the
|
||||
/// compressed first-gravity-frame settle runs, exactly like retail arms
|
||||
/// at the received position and only then simulates the first frame.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommitActivationArmsTheLoginConstraintLeashAtTheCommittedPlacement()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
residentWorld: true,
|
||||
terrainHeight: 2.7f,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var evaluation));
|
||||
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
ConstraintManager? constraint =
|
||||
controller.PositionManager?.Constraint;
|
||||
Assert.NotNull(constraint);
|
||||
Assert.True(constraint!.IsConstrained);
|
||||
// The anchor cell is the placement's committed containing cell.
|
||||
Assert.Equal(
|
||||
controller.PhysicsBody.CellPosition.ObjCellId,
|
||||
constraint.ConstraintPos.ObjCellId);
|
||||
// Anchored at the committed (floor-snapped) placement pose.
|
||||
Assert.InRange(
|
||||
constraint.ConstraintPos.Frame.Origin.Z, 2.65f, 2.76f);
|
||||
Assert.InRange(
|
||||
controller.PhysicsBody.Position.Z, 2.65f, 2.76f);
|
||||
Assert.Equal(
|
||||
ConstraintDistance.GetStartConstraintDistance(
|
||||
constraint.ConstraintPos.ObjCellId),
|
||||
constraint.ConstraintDistanceStart);
|
||||
Assert.Equal(
|
||||
ConstraintDistance.GetMaxConstraintDistance(
|
||||
constraint.ConstraintPos.ObjCellId),
|
||||
constraint.ConstraintDistanceMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review R1: the arm is exactly-once — the successful final
|
||||
/// commit nulls the activation envelope, so a stale CommitActivation
|
||||
/// retry is RejectedAuthority and can never re-arm the leash.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CommitActivationNeverRearmsTheLeashOnAStaleRetry()
|
||||
{
|
||||
using var fixture = new Fixture(
|
||||
residentWorld: true,
|
||||
terrainHeight: 2.7f,
|
||||
moverSphereOriginZ: 0.475f);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var evaluation));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
PlayerMovementController controller =
|
||||
Assert.IsType<PlayerMovementController>(
|
||||
fixture.Movement.Controller);
|
||||
Assert.True(
|
||||
controller.PositionManager!.Constraint!.IsConstrained);
|
||||
|
||||
controller.PositionManager.UnConstrain();
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
|
||||
fixture.Owner.CommitActivation(evaluation, out _));
|
||||
|
||||
Assert.False(
|
||||
controller.PositionManager.Constraint.IsConstrained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable()
|
||||
{
|
||||
|
|
@ -356,10 +525,117 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
Assert.Equal(1, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
|
||||
const ulong generation = 1UL;
|
||||
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u,
|
||||
generation);
|
||||
// C3c-F2: the wake is driven through the SAME owner production uses
|
||||
// (RuntimePhysicsState's collision admission), not through the raw
|
||||
// SetPosition seam. Driving SetPosition directly leaves the admission
|
||||
// ledger empty, which is a state production can never be in and which
|
||||
// hid the login rearm wedge this test is supposed to cover.
|
||||
CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u);
|
||||
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var ready));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(ready, out var projection));
|
||||
Assert.True(projection.IsValid);
|
||||
Assert.True(fixture.Record.PhysicsBody!.InWorld);
|
||||
Assert.Equal(0, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredCommitRearmsAfterProductionAdmissionCommitsItsGeneration()
|
||||
{
|
||||
// C3c-F2 regression (the connected-gate login wedge). The sibling
|
||||
// test above wakes the parked lease by calling BeginCollisionGeneration
|
||||
// / CommitCollisionGeneration DIRECTLY on the SetPosition state, which
|
||||
// leaves RuntimePhysicsState's admission ledger empty. PRODUCTION
|
||||
// always wakes through BeginCollisionAdmission -> stage ->
|
||||
// CommitCollisionGeneration, and that path retires the admission the
|
||||
// instant the generation commits — after which
|
||||
// ExpectedCollisionGeneration names the NEXT, never-begun generation.
|
||||
// The rearm must key off the generation the collision world now HOLDS.
|
||||
using var fixture = new Fixture();
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
|
||||
fixture.Owner.EvaluateActivation(token, out var deferred));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell,
|
||||
fixture.Owner.CommitActivation(deferred, out var noProjection));
|
||||
Assert.False(noProjection.IsValid);
|
||||
Assert.False(fixture.Record.PhysicsBody!.InWorld);
|
||||
Assert.False(fixture.Movement.Controller!.IsRuntimePublished);
|
||||
Assert.Equal(1, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
|
||||
RuntimeCollisionAdmission admission = fixture.Lifetime.Physics
|
||||
.BeginCollisionAdmission(Cell & 0xFFFF0000u);
|
||||
// While the destination admission is in flight the lease must simply
|
||||
// keep waiting — never a rejection, never an early rearm.
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
|
||||
fixture.Owner.EvaluateActivation(token, out var waiting));
|
||||
Assert.False(waiting.IsValid);
|
||||
using PreparedLandblockCollisionGeneration prepared = fixture
|
||||
.Lifetime.Physics.PrepareCollisionGeneration(admission);
|
||||
fixture.Lifetime.Physics.StageCollisionAssets(
|
||||
admission,
|
||||
prepared,
|
||||
CollisionAssets(Cell & 0xFFFF0000u));
|
||||
Assert.True(CommitPrepared(
|
||||
fixture.Lifetime.Physics,
|
||||
admission,
|
||||
prepared).Committed);
|
||||
|
||||
// The exact divergence this test pins: the committed authority is the
|
||||
// generation the lease parked on; "expected" has already moved past it.
|
||||
Assert.Equal(
|
||||
admission.Generation,
|
||||
fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell));
|
||||
Assert.NotEqual(
|
||||
admission.Generation,
|
||||
fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell));
|
||||
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var ready));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
fixture.Owner.CommitActivation(ready, out var projection));
|
||||
Assert.True(projection.IsValid);
|
||||
Assert.True(fixture.Record.PhysicsBody!.InWorld);
|
||||
Assert.NotNull(fixture.Record.PhysicsHost);
|
||||
Assert.True(fixture.Movement.Controller!.IsRuntimePublished);
|
||||
Assert.Equal(0, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredCommitStaysParkedWhileTheCommittingAdmissionIsStillRegistered()
|
||||
{
|
||||
// C3c-F2, the link the live probe caught: the collision-generation
|
||||
// commit marks the parked lease ready and only THEN retires its
|
||||
// admission (RuntimePhysicsState.cs:2503 vs :2552-2558). Production
|
||||
// reenters the first-entry pump inside that window. Rearming there
|
||||
// moves the lease out of AwaitingCell and its very next evaluation
|
||||
// fails the seal on the still-registered admission — after which
|
||||
// EvaluateActivation can no longer answer DeferredCell and reports
|
||||
// RejectedAuthority, which is TERMINAL for the conductor. The lease
|
||||
// must stay parked and retryable instead.
|
||||
using var fixture = new Fixture();
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
|
||||
fixture.Owner.Commit(
|
||||
fixture.Prepare(),
|
||||
out RuntimeLocalPlayerPhysicsActivationToken token));
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
|
||||
fixture.Owner.EvaluateActivation(token, out var deferred));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell,
|
||||
fixture.Owner.CommitActivation(deferred, out _));
|
||||
|
||||
RuntimeCollisionAdmission admission = fixture.Lifetime.Physics
|
||||
.BeginCollisionAdmission(Cell & 0xFFFF0000u);
|
||||
// Reconstruct the exact production instant: the landblock is in the
|
||||
// engine and the parked lease has been marked ready by the SAME call
|
||||
// RuntimePhysicsState.cs:2503 makes, while the admission that is
|
||||
// committing has not yet been retired (:2552-2558).
|
||||
fixture.Lifetime.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
|
|
@ -369,8 +645,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
worldOffsetY: 0f);
|
||||
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u,
|
||||
generation,
|
||||
admission.Generation,
|
||||
ready: true);
|
||||
Assert.Equal(
|
||||
admission.Generation,
|
||||
fixture.Lifetime.Physics.CollisionGenerationAuthority(Cell));
|
||||
Assert.Equal(
|
||||
admission.Generation,
|
||||
fixture.Lifetime.Physics.ExpectedCollisionGeneration(Cell));
|
||||
Assert.False(fixture.Lifetime.Physics
|
||||
.IsCollisionEvaluationPrefixAdmissible(Cell));
|
||||
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
|
||||
fixture.Owner.EvaluateActivation(token, out var stillWaiting));
|
||||
Assert.False(stillWaiting.IsValid);
|
||||
Assert.Equal(1, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
Assert.False(fixture.Record.PhysicsBody!.InWorld);
|
||||
Assert.False(fixture.Movement.Controller!.IsRuntimePublished);
|
||||
|
||||
using (PreparedLandblockCollisionGeneration prepared = fixture
|
||||
.Lifetime.Physics.PrepareCollisionGeneration(admission))
|
||||
{
|
||||
fixture.Lifetime.Physics.StageCollisionAssets(
|
||||
admission,
|
||||
prepared,
|
||||
CollisionAssets(Cell & 0xFFFF0000u));
|
||||
Assert.True(CommitPrepared(
|
||||
fixture.Lifetime.Physics,
|
||||
admission,
|
||||
prepared).Committed);
|
||||
}
|
||||
Assert.True(fixture.Lifetime.Physics
|
||||
.IsCollisionEvaluationPrefixAdmissible(Cell));
|
||||
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var ready));
|
||||
|
|
@ -378,6 +685,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
fixture.Owner.CommitActivation(ready, out var projection));
|
||||
Assert.True(projection.IsValid);
|
||||
Assert.True(fixture.Record.PhysicsBody!.InWorld);
|
||||
Assert.True(fixture.Movement.Controller!.IsRuntimePublished);
|
||||
Assert.Equal(0, fixture.Owner.CaptureOwnership()
|
||||
.PendingActivationCount);
|
||||
}
|
||||
|
|
@ -562,15 +870,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects
|
||||
.PendingSetPositionDispatchCount);
|
||||
|
||||
const ulong generation = 1UL;
|
||||
fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation);
|
||||
fixture.Lifetime.Physics.Engine.AddLandblock(
|
||||
Cell & 0xFFFF0000u,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||
fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
|
||||
Cell & 0xFFFF0000u, generation, ready: true);
|
||||
// C3c-F2: production wake path (collision admission), see
|
||||
// CommitProductionCollisionGeneration.
|
||||
CommitProductionCollisionGeneration(fixture, Cell & 0xFFFF0000u);
|
||||
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
|
||||
fixture.Owner.EvaluateActivation(token, out var ready));
|
||||
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
|
||||
|
|
@ -2192,6 +2494,11 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
private bool _lifetimeDisposed;
|
||||
// C3c-F5: default 0 keeps every pre-existing test's mover shape
|
||||
// byte-identical; the first-entry settle tests pass the production
|
||||
// human Setup's bottom-sphere origin (0.475) so an authored
|
||||
// placement can stand clear of a floor the settle then reaches.
|
||||
private readonly float _moverSphereOriginZ;
|
||||
|
||||
internal Fixture(
|
||||
bool preparePlacement = true,
|
||||
|
|
@ -2199,11 +2506,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
RuntimeLocalPlayerShadowDisposition shadowDisposition =
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless,
|
||||
float terrainHeight = 0f,
|
||||
float moverSphereOriginZ = 0f,
|
||||
Vector3? initialVelocity = null,
|
||||
Vector3? initialOmega = null,
|
||||
float? initialFriction = null,
|
||||
float? initialElasticity = null)
|
||||
{
|
||||
_moverSphereOriginZ = moverSphereOriginZ;
|
||||
if (residentWorld)
|
||||
{
|
||||
var engine = new PhysicsEngine
|
||||
|
|
@ -2269,7 +2578,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
Assert.True(Placement.IsValid);
|
||||
var setup = new FlatSetupCollision(
|
||||
ImmutableArray<FlatCollisionCylinder>.Empty,
|
||||
[new FlatCollisionSphere(Vector3.Zero, 0.48f)],
|
||||
[new FlatCollisionSphere(
|
||||
new Vector3(0f, 0f, _moverSphereOriginZ),
|
||||
0.48f)],
|
||||
height: 0f,
|
||||
radius: 0f,
|
||||
stepUpHeight: 0.4f,
|
||||
|
|
@ -2537,6 +2848,31 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
|
|||
"Collision generation did not complete its Runtime mutation transaction.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F2: publishes one landblock collision generation through the exact
|
||||
/// production owner chain — BeginCollisionAdmission -> prepare -> stage ->
|
||||
/// CommitCollisionGeneration. Waking a parked activation any other way
|
||||
/// (calling the SetPosition seam directly) leaves RuntimePhysicsState's
|
||||
/// admission ledger empty, a state production can never reach.
|
||||
/// </summary>
|
||||
private static void CommitProductionCollisionGeneration(
|
||||
Fixture fixture,
|
||||
uint landblockId)
|
||||
{
|
||||
RuntimeCollisionAdmission admission = fixture.Lifetime.Physics
|
||||
.BeginCollisionAdmission(landblockId);
|
||||
using PreparedLandblockCollisionGeneration prepared = fixture
|
||||
.Lifetime.Physics.PrepareCollisionGeneration(admission);
|
||||
fixture.Lifetime.Physics.StageCollisionAssets(
|
||||
admission,
|
||||
prepared,
|
||||
CollisionAssets(landblockId));
|
||||
Assert.True(CommitPrepared(
|
||||
fixture.Lifetime.Physics,
|
||||
admission,
|
||||
prepared).Committed);
|
||||
}
|
||||
|
||||
private static RuntimeLandblockCollisionAssets CollisionAssets(
|
||||
uint landblockId) => new(
|
||||
landblockId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,356 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// C3c-F3: landblock (0,0) — id 0x0000FFFF, Dereth's south-west map corner —
|
||||
/// has the legitimate collision prefix 0x00000000. The prefix-0 "absent"
|
||||
/// sentinel used to make every collision publication, quiescence, park, wake,
|
||||
/// and retirement against that landblock throw
|
||||
/// <see cref="ArgumentOutOfRangeException"/> from
|
||||
/// <c>RuntimeSetPositionState.BeginCollisionPrefixQuiescence</c> (the
|
||||
/// connected-gate crash at teleport destination (9,4), whose far streaming
|
||||
/// radius reaches the corner: logs/connected-world-gate-20260802-135444).
|
||||
/// These tests drive the exact production owner chain against the corner id
|
||||
/// and, for quiescence semantics, assert step-for-step parity with a
|
||||
/// nonzero-prefix landblock.
|
||||
/// </summary>
|
||||
public sealed partial class RuntimeCollisionPrefixQuiescenceTests
|
||||
{
|
||||
private const uint CornerLandblock = 0x0000FFFFu;
|
||||
private const uint CornerPrefix = 0x00000000u;
|
||||
private const uint CornerCell = 0x00000001u;
|
||||
private const uint CornerCell2 = 0x00000002u;
|
||||
private const uint CornerIndoorCell = 0x00000100u;
|
||||
private const uint NeighborLandblock = 0x0001FFFFu;
|
||||
|
||||
[Fact]
|
||||
public void CornerLandblockCollisionGenerationCommitsThroughTheProductionAdmissionChain()
|
||||
{
|
||||
// Empty engine — production streaming publishes the corner landblock
|
||||
// from nothing, exactly like LandblockPhysicsPublisher.AdvanceCompleteOne.
|
||||
using var fixture = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: new PhysicsEngine { DataCache = new PhysicsDataCache() });
|
||||
RuntimePhysicsState physics = fixture.Lifetime.Physics;
|
||||
|
||||
RuntimeCollisionAdmission admission =
|
||||
physics.BeginCollisionAdmission(CornerLandblock);
|
||||
Assert.Equal(CornerLandblock, admission.LandblockId);
|
||||
using PreparedLandblockCollisionGeneration prepared =
|
||||
PrepareSealedMutation(physics, admission, CornerLandblock);
|
||||
// Pre-fix this first commit threw ArgumentOutOfRangeException
|
||||
// ("landblockId") from BeginCollisionPrefixQuiescence's prefix == 0
|
||||
// sentinel guard.
|
||||
RuntimeCollisionGenerationCommit commit =
|
||||
CommitToCompletion(physics, admission, prepared);
|
||||
Assert.True(commit.Completed);
|
||||
Assert.True(physics.Engine.IsLandblockTerrainResident(CornerLandblock));
|
||||
|
||||
// The neighbouring landblock (0,1) — prefix 0x00010000 — publishes
|
||||
// identically through the same chain.
|
||||
RuntimeCollisionAdmission neighborAdmission =
|
||||
physics.BeginCollisionAdmission(NeighborLandblock);
|
||||
using PreparedLandblockCollisionGeneration neighborPrepared =
|
||||
PrepareSealedMutation(physics, neighborAdmission, NeighborLandblock);
|
||||
RuntimeCollisionGenerationCommit neighborCommit =
|
||||
CommitToCompletion(physics, neighborAdmission, neighborPrepared);
|
||||
Assert.True(neighborCommit.Completed);
|
||||
Assert.True(
|
||||
physics.Engine.IsLandblockTerrainResident(NeighborLandblock));
|
||||
|
||||
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
|
||||
Assert.Equal(0, ownership.CollisionPrefixMutationCount);
|
||||
Assert.Equal(0, ownership.CollisionPrefixQuiescenceCount);
|
||||
Assert.Equal(0, ownership.PendingCollisionPrefixProjectionCount);
|
||||
Assert.Equal(0, ownership.CollisionAdmissionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CornerResidentParksAndRestoresAcrossAnActivationReplacement()
|
||||
{
|
||||
// Mirror of ActivationWaitsForExactWithdrawAndPlaceReceipts against
|
||||
// the corner landblock: publish/park (ParkDeferred's quiescence
|
||||
// override carries prefix 0x00000000), wake, and restore.
|
||||
using var fixture = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: CornerEngine());
|
||||
RuntimeEntityRecord record = fixture.Add(
|
||||
0x700031F1u,
|
||||
1,
|
||||
CornerCell,
|
||||
new Vector3(11f, 12f, 0f));
|
||||
RuntimeSetPositionOutcome seeded = fixture.Place(
|
||||
record,
|
||||
CornerCell,
|
||||
new Vector3(11.5f, 12f, 0f));
|
||||
// SetPosition against a corner cell commits (host acknowledgement of
|
||||
// the Place projection is the ordinary pending suffix, identical to
|
||||
// any nonzero-prefix landblock).
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
seeded.Status);
|
||||
Assert.True(fixture.Lifetime.Physics.SetPosition
|
||||
.AcknowledgeProjection(seeded.Projection));
|
||||
|
||||
RuntimePhysicsState physics = fixture.Lifetime.Physics;
|
||||
RuntimeCollisionAdmission admission =
|
||||
physics.BeginCollisionAdmission(CornerLandblock);
|
||||
using PreparedLandblockCollisionGeneration prepared =
|
||||
PrepareSealedMutation(physics, admission, CornerLandblock);
|
||||
|
||||
RuntimeCollisionGenerationCommit first =
|
||||
physics.CommitCollisionGeneration(admission, prepared);
|
||||
Assert.False(first.EngineCommitted);
|
||||
Assert.False(first.Completed);
|
||||
Assert.False(physics.IsSpatialRoot(record));
|
||||
Assert.True(physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot withdrawn));
|
||||
Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawn.Kind);
|
||||
|
||||
Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawn.Token));
|
||||
_ = SealMutation(physics, admission, prepared);
|
||||
RuntimeCollisionGenerationCommit transferred =
|
||||
physics.CommitCollisionGeneration(admission, prepared);
|
||||
Assert.True(transferred.EngineCommitted);
|
||||
Assert.False(transferred.Completed);
|
||||
Assert.True(physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot restored));
|
||||
Assert.Equal(RuntimePlacementProjectionKind.Place, restored.Kind);
|
||||
|
||||
Assert.True(physics.SetPosition.AcknowledgeProjection(restored.Token));
|
||||
RuntimeCollisionGenerationCommit completed =
|
||||
physics.CommitCollisionGeneration(admission, prepared);
|
||||
Assert.True(completed.Completed);
|
||||
Assert.True(physics.IsSpatialRoot(record));
|
||||
Assert.Equal(CornerCell, record.FullCellId);
|
||||
Assert.Equal(0, physics.CaptureOwnership().CollisionPrefixMutationCount);
|
||||
Assert.Equal(
|
||||
0,
|
||||
physics.CaptureOwnership().CollisionPrefixQuiescenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CornerPrefixQuiescenceHoldsAndReleasesExactlyLikeANonzeroPrefix()
|
||||
{
|
||||
// Contract test 2: a parked deferral against the prefix-0 landblock
|
||||
// holds and releases quiescence exactly like a nonzero-prefix
|
||||
// landblock. The identical script runs against both and every step's
|
||||
// observable outcome must match.
|
||||
using var corner = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: CornerEngine());
|
||||
using var control = new Fixture(bindGeneration: true);
|
||||
|
||||
// The corner run addresses landblock (0,0) by its canonical id
|
||||
// 0x0000FFFF — the raw input 0x00000000 stays reserved for "absent"
|
||||
// (see AbsentLandblockIdStillCannotBeginQuiescence).
|
||||
List<string> cornerLog = RunHeldPlacementQuiescenceCycle(
|
||||
corner,
|
||||
CornerLandblock,
|
||||
sourceCell: CornerCell,
|
||||
targetCell: CornerCell2,
|
||||
guid: 0x700031F2u);
|
||||
List<string> controlLog = RunHeldPlacementQuiescenceCycle(
|
||||
control,
|
||||
PrefixP,
|
||||
sourceCell: CellP,
|
||||
targetCell: PrefixP | 0x0002u,
|
||||
guid: 0x700031F3u);
|
||||
|
||||
Assert.Equal(controlLog, cornerLog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CornerLandblockDemotesAndWithdrawsThroughRetirementMutations()
|
||||
{
|
||||
using (var demoteFixture = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: CornerEngine()))
|
||||
{
|
||||
RuntimeEntityRecord outdoor = demoteFixture.Add(
|
||||
0x700031F4u,
|
||||
1,
|
||||
CornerCell,
|
||||
new Vector3(12f, 41f, 0f));
|
||||
RuntimeEntityRecord indoor = demoteFixture.Add(
|
||||
0x700031F5u,
|
||||
1,
|
||||
CornerIndoorCell,
|
||||
new Vector3(13f, 41f, 0f));
|
||||
|
||||
RuntimeCollisionMutationResult first = demoteFixture.Lifetime
|
||||
.Physics.DemoteCollisionToTerrain(CornerLandblock);
|
||||
Assert.False(first.Completed);
|
||||
Assert.True(demoteFixture.Lifetime.Physics.IsSpatialRoot(outdoor));
|
||||
Assert.False(demoteFixture.Lifetime.Physics.IsSpatialRoot(indoor));
|
||||
Assert.True(demoteFixture.Lifetime.Physics.SetPosition
|
||||
.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot withdrawal));
|
||||
Assert.Equal(indoor.Key, withdrawal.Token.Entity);
|
||||
Assert.True(demoteFixture.Lifetime.Physics.SetPosition
|
||||
.AcknowledgeProjection(withdrawal.Token));
|
||||
|
||||
RuntimeCollisionMutationResult completed = demoteFixture.Lifetime
|
||||
.Physics.DemoteCollisionToTerrain(CornerLandblock);
|
||||
Assert.True(completed.Completed);
|
||||
Assert.True(completed.Ready);
|
||||
}
|
||||
|
||||
using var withdrawFixture = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: CornerEngine());
|
||||
RuntimeEntityRecord record = withdrawFixture.Add(
|
||||
0x700031F6u,
|
||||
1,
|
||||
CornerCell,
|
||||
new Vector3(14f, 42f, 0f));
|
||||
RuntimeSetPositionOutcome seeded = withdrawFixture.Place(
|
||||
record,
|
||||
CornerCell,
|
||||
new Vector3(14.5f, 42f, 0f));
|
||||
Assert.True(withdrawFixture.Lifetime.Physics.SetPosition
|
||||
.AcknowledgeProjection(seeded.Projection));
|
||||
RuntimePhysicsState physics = withdrawFixture.Lifetime.Physics;
|
||||
|
||||
RuntimeCollisionMutationResult pending =
|
||||
physics.WithdrawCollision(CornerLandblock);
|
||||
Assert.False(pending.Completed);
|
||||
Assert.True(physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot removed));
|
||||
Assert.True(physics.SetPosition.AcknowledgeProjection(removed.Token));
|
||||
RuntimeCollisionMutationResult withdrawn =
|
||||
physics.WithdrawCollision(CornerLandblock);
|
||||
Assert.True(withdrawn.Completed);
|
||||
Assert.False(withdrawn.Ready);
|
||||
Assert.False(physics.IsSpatialRoot(record));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentLandblockIdStillCannotBeginQuiescence()
|
||||
{
|
||||
// The prefix-0 sentinel accidentally rejected the corner landblock;
|
||||
// the genuine "no landblock at all" input (id 0) must keep throwing.
|
||||
using var fixture = new Fixture(
|
||||
bindGeneration: true,
|
||||
engine: CornerEngine());
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => fixture.Begin(0u, 2UL, includeOutdoorCells: true));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => fixture.Lifetime.Physics.BeginCollisionAdmission(0u));
|
||||
|
||||
RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(
|
||||
CornerLandblock,
|
||||
2UL,
|
||||
includeOutdoorCells: true);
|
||||
Assert.True(token.IsValid);
|
||||
Assert.Equal(CornerPrefix, token.LandblockPrefix);
|
||||
Assert.True(fixture.Lifetime.Physics.CancelCollisionPrefixQuiescence(
|
||||
token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One held-placement quiescence cycle (the
|
||||
/// SourceToOutsidePlacementIsHeldThenRestoredBeforeBarrierOpens shape,
|
||||
/// single-prefix variant), with every observable step outcome recorded so
|
||||
/// two runs can be compared for exact parity.
|
||||
/// </summary>
|
||||
private static List<string> RunHeldPlacementQuiescenceCycle(
|
||||
Fixture fixture,
|
||||
uint landblockId,
|
||||
uint sourceCell,
|
||||
uint targetCell,
|
||||
uint guid)
|
||||
{
|
||||
var log = new List<string>();
|
||||
RuntimeEntityRecord record = fixture.Add(
|
||||
guid,
|
||||
1,
|
||||
sourceCell,
|
||||
new Vector3(10f, 22f, 0f));
|
||||
RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(
|
||||
landblockId,
|
||||
2UL,
|
||||
includeOutdoorCells: true);
|
||||
log.Add($"tokenValid={token.IsValid}");
|
||||
|
||||
RuntimeSetPositionOutcome held = fixture.Place(
|
||||
record,
|
||||
targetCell,
|
||||
new Vector3(14f, 22f, 0f),
|
||||
currentCell: sourceCell);
|
||||
log.Add($"place={held.Status}");
|
||||
log.Add($"placeGeneration={held.Projection.CollisionGeneration}");
|
||||
log.Add($"placeCellLow={held.ExactCellId & 0xFFFFu:X4}");
|
||||
log.Add(
|
||||
$"root={fixture.Lifetime.Physics.IsSpatialRoot(record)}");
|
||||
log.Add($"ackWithdraw={fixture.Lifetime.Physics.SetPosition
|
||||
.AcknowledgeProjection(held.Projection)}");
|
||||
log.Add($"acquire1={fixture.TryAcquire(token, out _)}");
|
||||
log.Add($"acquire2={fixture.TryAcquire(token, out _)}");
|
||||
|
||||
log.Add($"cancelRestorePending={fixture.Lifetime.Physics
|
||||
.CancelCollisionPrefixQuiescence(
|
||||
token,
|
||||
successorGeneration: 1UL,
|
||||
successorReady: true)}");
|
||||
bool peeked = fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot restored);
|
||||
log.Add($"restorePeeked={peeked}");
|
||||
log.Add($"restoreKind={restored.Kind}");
|
||||
log.Add($"restoreCellLow={restored.Token.ExactCellId & 0xFFFFu:X4}");
|
||||
log.Add($"ackRestore={fixture.Lifetime.Physics.SetPosition
|
||||
.AcknowledgeProjection(restored.Token)}");
|
||||
log.Add($"cancelCompleted={fixture.Lifetime.Physics
|
||||
.CancelCollisionPrefixQuiescence(
|
||||
token,
|
||||
successorGeneration: 1UL,
|
||||
successorReady: true)}");
|
||||
log.Add($"finalCellLow={record.FullCellId & 0xFFFFu:X4}");
|
||||
log.Add(
|
||||
$"finalRoot={fixture.Lifetime.Physics.IsSpatialRoot(record)}");
|
||||
RuntimePhysicsOwnershipSnapshot ownership =
|
||||
fixture.Lifetime.Physics.CaptureOwnership();
|
||||
log.Add($"quiescences={ownership.CollisionPrefixQuiescenceCount}");
|
||||
log.Add(
|
||||
$"pendingProjections={ownership.PendingCollisionPrefixProjectionCount}");
|
||||
return log;
|
||||
}
|
||||
|
||||
private static RuntimeCollisionGenerationCommit CommitToCompletion(
|
||||
RuntimePhysicsState physics,
|
||||
RuntimeCollisionAdmission admission,
|
||||
PreparedLandblockCollisionGeneration prepared)
|
||||
{
|
||||
for (int poll = 0; poll < 10_000; poll++)
|
||||
{
|
||||
RuntimeCollisionGenerationCommit commit =
|
||||
physics.CommitCollisionGeneration(admission, prepared);
|
||||
if (commit.Completed)
|
||||
return commit;
|
||||
while (physics.SetPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot projection))
|
||||
{
|
||||
Assert.True(physics.SetPosition.AcknowledgeProjection(
|
||||
projection.Token));
|
||||
}
|
||||
if (!commit.EngineCommitted)
|
||||
_ = SealMutation(physics, admission, prepared);
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"Collision generation did not complete its mutation transaction.");
|
||||
}
|
||||
|
||||
private static PhysicsEngine CornerEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine
|
||||
{
|
||||
DataCache = new PhysicsDataCache(),
|
||||
};
|
||||
AddFlatLandblock(engine, CornerPrefix);
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,18 +17,33 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
[Fact]
|
||||
public void DirectSinkOwnsCanonicalCreateUpdateDeleteWithoutProjection()
|
||||
{
|
||||
using GameRuntime runtime = CreateRuntime();
|
||||
// C3c: the direct sink's Create now enters the canonical initial
|
||||
// residence; this test drives the remote first-entry conductor to
|
||||
// completion (the direct-host pump's job) before the follow-up
|
||||
// Position flows the ordinary post-residence path.
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
CommitLandblockCollision(runtime, 0x01010000u);
|
||||
RuntimeFirstEntryDriveController drive = CreateDrive(runtime);
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
new FixtureTransport());
|
||||
// C3c-R1 review R3: the residence route requires a drive-backed
|
||||
// world projection — a content-less (projection-less) controller
|
||||
// keeps the pre-flip legacy registration instead (see
|
||||
// ContentLessDirectSink_KeepsPreFlipLegacyRegistration).
|
||||
var controller = new RuntimeLiveEntitySessionController(
|
||||
runtime,
|
||||
session);
|
||||
session,
|
||||
worldProjection: new FixtureWorldProjection());
|
||||
LiveEntitySessionSink sink = controller.CreateSink();
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(0x70000001u, incarnation: 1);
|
||||
|
||||
sink.Spawned(spawn);
|
||||
drive.DriveAll();
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
DrainPlacementFifo(runtime);
|
||||
sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value with
|
||||
|
|
@ -62,10 +77,79 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
runtime.EntityObjects.Entities.PendingTeardownCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1 review R3: a CONTENT-LESS headless host (validated-legal
|
||||
/// configuration: HeadlessConfigurationLoader accepts a null
|
||||
/// process.content) builds no world projection and no first-entry
|
||||
/// drive. Its Creates must keep the exact pre-flip legacy registration
|
||||
/// — the accepted frame commits directly (FullCellId derives from the
|
||||
/// wire cell at registration), no residence lease ever opens, and the
|
||||
/// entity/residence/drive ledgers stay at zero — because a residence
|
||||
/// with no drive to pump it would park every Create (and every packet
|
||||
/// FIFO'd behind its pending residence) forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContentLessDirectSink_KeepsPreFlipLegacyRegistration()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 9000),
|
||||
new FixtureTransport());
|
||||
var controller = new RuntimeLiveEntitySessionController(
|
||||
runtime,
|
||||
session);
|
||||
LiveEntitySessionSink sink = controller.CreateSink();
|
||||
WorldSession.EntitySpawn spawn =
|
||||
Spawn(0x70000003u, incarnation: 1);
|
||||
|
||||
sink.Spawned(spawn);
|
||||
|
||||
Assert.True(
|
||||
runtime.EntityObjects.Entities.TryGetActive(
|
||||
spawn.Guid,
|
||||
out RuntimeEntityRecord canonical));
|
||||
Assert.Equal(
|
||||
spawn.Position!.Value.LandblockId,
|
||||
canonical.FullCellId);
|
||||
RuntimeEntityObjectOwnershipSnapshot ownership =
|
||||
runtime.EntityObjects.CaptureOwnership();
|
||||
Assert.Equal(0, ownership.InitialCreateResidenceLeaseCount);
|
||||
Assert.Equal(0, ownership.FirstEntryDrivePendingCount);
|
||||
Assert.Equal(1, runtime.Entities.Count);
|
||||
Assert.Equal(1, runtime.Inventory.ObjectCount);
|
||||
|
||||
// Position packets flow the ordinary immediate path — nothing is
|
||||
// FIFO'd behind a pending residence.
|
||||
sink.PositionUpdated(new WorldSession.EntityPositionUpdate(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value with
|
||||
{
|
||||
PositionX = 20f,
|
||||
},
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: 2,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0));
|
||||
Assert.Equal(
|
||||
20f,
|
||||
canonical.Snapshot.Position!.Value.PositionX);
|
||||
|
||||
sink.Deleted(new DeleteObject.Parsed(spawn.Guid, 1));
|
||||
Assert.Equal(0, runtime.Entities.Count);
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Entities.PendingTeardownCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectSinkCompletesExactPortalAndSendsLoginComplete()
|
||||
{
|
||||
using GameRuntime runtime = CreateRuntime();
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
const uint playerGuid = 0x50000001u;
|
||||
runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
using var session = new WorldSession(
|
||||
|
|
@ -106,7 +190,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
[Fact]
|
||||
public void DirectSinkProjectsAcceptedLocalWorldStateThroughOneHostSeam()
|
||||
{
|
||||
using GameRuntime runtime = CreateRuntime();
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
const uint playerGuid = 0x50000002u;
|
||||
runtime.PlayerIdentity.ServerGuid = playerGuid;
|
||||
using var session = new WorldSession(
|
||||
|
|
@ -154,16 +239,268 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Assert.True(runtime.Portal.Snapshot.Completed);
|
||||
}
|
||||
|
||||
private static GameRuntime CreateRuntime()
|
||||
/// <summary>
|
||||
/// C3c-R1 review F6: the drive controller outlives its session routes,
|
||||
/// so "session reset precedes a new route" is an asserted latch, not a
|
||||
/// silent assumption — a second route cannot attach before the prior
|
||||
/// route detached, and a route that never owned the drive cannot clear
|
||||
/// the live route's tracked entries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FirstEntryDriveServesOneRouteAtATimeAndScopesClearToTheOwner()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
RuntimeFirstEntryDriveController drive = CreateDrive(runtime);
|
||||
var routeA = new object();
|
||||
var routeB = new object();
|
||||
|
||||
drive.AttachRoute(routeA);
|
||||
// Re-attaching the same owner is a no-op; a SECOND route asserts.
|
||||
drive.AttachRoute(routeA);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => drive.AttachRoute(routeB));
|
||||
|
||||
_ = runtime.EntityObjects.RegisterEntityWithInitialResidence(
|
||||
Spawn(0x70000004u, incarnation: 1),
|
||||
isLocalPlayer: false);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
// A non-owner detach (never-attached / displaced route rollback)
|
||||
// must not clear the live route's entries.
|
||||
drive.DetachRoute(routeB);
|
||||
Assert.Equal(1, drive.PendingCount);
|
||||
|
||||
drive.DetachRoute(routeA);
|
||||
Assert.Equal(0, drive.PendingCount);
|
||||
// After the owner detached, a replacement route may attach.
|
||||
drive.AttachRoute(routeB);
|
||||
drive.DetachRoute(routeB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c: initial-residence admission requires a live session generation
|
||||
/// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct
|
||||
/// sink tests start one through the same fixture-session shape
|
||||
/// DirectGameRuntimeCommandAdapterTests uses.
|
||||
/// </summary>
|
||||
private sealed class StartedRuntime : IDisposable
|
||||
{
|
||||
internal required GameRuntime Runtime { get; init; }
|
||||
internal required LiveSessionHost Live { get; init; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ = Live.Stop(Runtime.Generation);
|
||||
Runtime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static StartedRuntime StartRuntime()
|
||||
{
|
||||
var operations = new FixtureGameplayOperations();
|
||||
var sessionOperations = new FixtureSessionOperations();
|
||||
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
||||
operations,
|
||||
operations,
|
||||
operations,
|
||||
operations));
|
||||
operations,
|
||||
SessionOperations: sessionOperations));
|
||||
operations.Bind(runtime);
|
||||
return runtime;
|
||||
var resetHost = new FixtureResetHost();
|
||||
var options = new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
9000,
|
||||
"account",
|
||||
"password");
|
||||
var live = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
new LiveSessionRoutingFactories(
|
||||
_ => new FixtureEventRoute(),
|
||||
_ => new FixtureCommandRoute()),
|
||||
generation => runtime.ResetGeneration(generation, resetHost),
|
||||
new LiveSessionSelectionBindings(
|
||||
id => runtime.PlayerIdentity.ServerGuid = id,
|
||||
_ => { },
|
||||
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
||||
_ => { },
|
||||
_ => { },
|
||||
runtime.ActionOwner.Combat.Clear),
|
||||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { },
|
||||
() => { },
|
||||
() => { },
|
||||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
Assert.NotEqual(0UL, runtime.Generation.Value);
|
||||
return new StartedRuntime { Runtime = runtime, Live = live };
|
||||
}
|
||||
|
||||
private static void CommitLandblockCollision(
|
||||
GameRuntime runtime,
|
||||
uint landblockId)
|
||||
{
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
landblockId, 1UL);
|
||||
runtime.EntityObjects.Physics.Engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId, 1UL, ready: true);
|
||||
}
|
||||
|
||||
private static RuntimeFirstEntryDriveController CreateDrive(
|
||||
GameRuntime runtime) =>
|
||||
new(
|
||||
runtime.EntityObjects,
|
||||
runtime.Clock,
|
||||
new UnusedCollisionSource(),
|
||||
() => PlayerMovementConstructionOptions.Fallback,
|
||||
static _ => new RuntimeLocalPlayerPhysicsActivationPreparation(
|
||||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
|
||||
/// <summary>
|
||||
/// Drains/acknowledges every still-pending placement receipt (the
|
||||
/// ExecutorCompleted correlation is reaped by its acknowledgement) the
|
||||
/// way a host subscription would.
|
||||
/// </summary>
|
||||
private static void DrainPlacementFifo(GameRuntime runtime)
|
||||
{
|
||||
while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out AcDream.Runtime.Physics.RuntimePlacementProjectionSnapshot head))
|
||||
{
|
||||
if (!runtime.EntityObjects.Physics.SetPosition
|
||||
.AcknowledgeProjection(head.Token))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class UnusedCollisionSource
|
||||
: AcDream.Content.IPreparedCollisionSource
|
||||
{
|
||||
public AcDream.Content.PreparedAssetPresence ProbeCollision(
|
||||
AcDream.Content.Pak.PakAssetType type,
|
||||
uint sourceFileId) =>
|
||||
AcDream.Content.PreparedAssetPresence.Available;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatSetupCollision>.Missing;
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionReadResult<
|
||||
AcDream.Core.Physics.FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public AcDream.Content.PreparedCollisionSourceStats CollisionStats =>
|
||||
default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) =>
|
||||
new(endpoint, new FixtureTransport());
|
||||
|
||||
public void Connect(WorldSession session, string user, string password)
|
||||
{
|
||||
}
|
||||
|
||||
public CharacterList.Parsed GetCharacters(WorldSession session) =>
|
||||
new(
|
||||
0u,
|
||||
[new CharacterList.Character(0x50000001u, "Direct", 0u)],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||
{
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session) =>
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FixtureEventRoute : ILiveSessionEventRouting
|
||||
{
|
||||
public void Attach()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureCommandRoute : ILiveSessionCommandRouting
|
||||
{
|
||||
public void Activate()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureResetHost : IRuntimeGenerationResetHost
|
||||
{
|
||||
public void RetireEntityProjection(RuntimeEntityRecord entity)
|
||||
{
|
||||
}
|
||||
|
||||
public void DrainEntityProjectionBoundary()
|
||||
{
|
||||
}
|
||||
|
||||
public void CompleteEntityProjectionRetirement()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
|
|
@ -194,7 +531,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
|
|
@ -213,7 +550,7 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue