refactor(world): extract live entity hydration
This commit is contained in:
parent
27dcd0ecb7
commit
d10c5f2d54
17 changed files with 4310 additions and 1269 deletions
1119
src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs
Normal file
1119
src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -36,7 +36,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
|
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
|
||||||
|
|
||||||
/// <summary>Raised after the attached projection is fully registered.</summary>
|
/// <summary>Raised after the attached projection is fully registered.</summary>
|
||||||
public event Action<uint>? EntityReady;
|
internal event Action<LiveEntityReadyCandidate>? EntityReady;
|
||||||
/// <summary>Raised after an attached projection has its composed pose.</summary>
|
/// <summary>Raised after an attached projection has its composed pose.</summary>
|
||||||
public event Action<uint>? ProjectionPoseReady;
|
public event Action<uint>? ProjectionPoseReady;
|
||||||
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
|
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
|
||||||
|
|
@ -448,14 +448,46 @@ public sealed class EquippedChildRenderController : IDisposable
|
||||||
// CPhysicsObj::set_hidden (0x00514C60).
|
// CPhysicsObj::set_hidden (0x00514C60).
|
||||||
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||||
}
|
}
|
||||||
|
ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion;
|
||||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||||
$"location={parentLocation} placement={placement}");
|
$"location={parentLocation} placement={placement}");
|
||||||
Relations.MarkProjected(pending, candidateKind);
|
Relations.MarkProjected(pending, candidateKind);
|
||||||
ProjectionPoseReady?.Invoke(childGuid);
|
ProjectionPoseReady?.Invoke(childGuid);
|
||||||
EntityReady?.Invoke(childGuid);
|
return PublishEntityReadyExact(
|
||||||
return true;
|
_liveEntities,
|
||||||
|
childRecord,
|
||||||
|
readyCreateIntegrationVersion,
|
||||||
|
entity,
|
||||||
|
EntityReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool PublishEntityReadyExact(
|
||||||
|
LiveEntityRuntime runtime,
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion,
|
||||||
|
WorldEntity expectedEntity,
|
||||||
|
Action<LiveEntityReadyCandidate>? publish)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedEntity);
|
||||||
|
if (!runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
|| !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish?.Invoke(new LiveEntityReadyCandidate(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion));
|
||||||
|
return runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
&& ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PublishChildPose(
|
private void PublishChildPose(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,105 @@
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes a fresher same-incarnation CreateObject after it interrupted an
|
||||||
|
/// active projection transaction. Logical entity/resource ownership is
|
||||||
|
/// retained; the exact current appearance is republished, the plugin/current
|
||||||
|
/// snapshot is refreshed, and the retained animation owner is synchronized in
|
||||||
|
/// that order. Every potentially reentrant stage is guarded by the monotonic
|
||||||
|
/// Create-integration authority.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveEntityCreateSupersessionRecovery
|
||||||
|
{
|
||||||
|
public static bool TryApply(
|
||||||
|
LiveEntityRuntime runtime,
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion,
|
||||||
|
Func<LiveEntityAppearanceUpdateState?> captureAppearance,
|
||||||
|
Func<LiveEntityAppearanceUpdateState, bool> publishAppearance,
|
||||||
|
Action<LiveEntityAppearanceUpdateState> publishCurrentSnapshot,
|
||||||
|
Action<LiveEntityAppearanceUpdateState> synchronizeAnimation)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
ArgumentNullException.ThrowIfNull(captureAppearance);
|
||||||
|
ArgumentNullException.ThrowIfNull(publishAppearance);
|
||||||
|
ArgumentNullException.ThrowIfNull(publishCurrentSnapshot);
|
||||||
|
ArgumentNullException.ThrowIfNull(synchronizeAnimation);
|
||||||
|
|
||||||
|
if (!runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
|| captureAppearance() is not { } visualUpdate
|
||||||
|
|| !runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
|| !publishAppearance(visualUpdate)
|
||||||
|
|| !runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
publishCurrentSnapshot(visualUpdate);
|
||||||
|
if (!runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronizeAnimation(visualUpdate);
|
||||||
|
return runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles the exceptional case where a fresher same-incarnation Create
|
||||||
|
/// interrupts initial PartArray construction. Completed owners already route
|
||||||
|
/// MovementData through SetObjectMovement and therefore retain their exact
|
||||||
|
/// sequencer, dispatch sink, and paired manager/interpreter queues.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LiveEntityCreateAnimationSynchronization
|
||||||
|
{
|
||||||
|
public static bool TrySynchronizeInterruptedInitialOwner(
|
||||||
|
LiveEntityRecord record,
|
||||||
|
LiveEntityAnimationState animation,
|
||||||
|
Animation? canonicalAnimation,
|
||||||
|
int canonicalLowFrame,
|
||||||
|
int canonicalHighFrame,
|
||||||
|
float canonicalFramerate,
|
||||||
|
MotionTable? motionTable,
|
||||||
|
CreateObject.ServerMotionState? wireState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
ArgumentNullException.ThrowIfNull(animation);
|
||||||
|
if (record.InitialHydrationCompleted)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (canonicalAnimation is not null)
|
||||||
|
{
|
||||||
|
animation.Animation = canonicalAnimation;
|
||||||
|
animation.LowFrame = canonicalLowFrame;
|
||||||
|
animation.HighFrame = canonicalHighFrame;
|
||||||
|
animation.Framerate = canonicalFramerate;
|
||||||
|
animation.CurrFrame = canonicalLowFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animation.Sequencer is { } sequencer && motionTable is not null)
|
||||||
|
{
|
||||||
|
SpawnMotionInitializer.Reinitialize(
|
||||||
|
sequencer,
|
||||||
|
motionTable,
|
||||||
|
wireState);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,26 @@ internal static class SpawnMotionInitializer
|
||||||
return sequencer;
|
return sequencer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replays description-time MovementData into the same retained PartArray
|
||||||
|
/// sequencer. This is used only when a fresher same-incarnation CreateObject
|
||||||
|
/// interrupted initial hydration before the object crossed its ready edge;
|
||||||
|
/// completed objects consume the packet through SetObjectMovement instead.
|
||||||
|
/// </summary>
|
||||||
|
public static void Reinitialize(
|
||||||
|
AnimationSequencer sequencer,
|
||||||
|
MotionTable motionTable,
|
||||||
|
CreateObject.ServerMotionState? wireState)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sequencer);
|
||||||
|
ArgumentNullException.ThrowIfNull(motionTable);
|
||||||
|
Plan plan = ResolvePlan(motionTable, wireState);
|
||||||
|
sequencer.Reset();
|
||||||
|
sequencer.InitializeState();
|
||||||
|
sequencer.SetCycle(plan.Style, plan.Motion);
|
||||||
|
sequencer.Manager.HandleEnterWorld();
|
||||||
|
}
|
||||||
|
|
||||||
internal static Plan ResolvePlan(
|
internal static Plan ResolvePlan(
|
||||||
MotionTable motionTable,
|
MotionTable motionTable,
|
||||||
CreateObject.ServerMotionState? wireState)
|
CreateObject.ServerMotionState? wireState)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ public sealed record RuntimeOptions(
|
||||||
bool RetailCloseDegrades,
|
bool RetailCloseDegrades,
|
||||||
bool DumpSceneryZ,
|
bool DumpSceneryZ,
|
||||||
bool DumpLiveSpawns,
|
bool DumpLiveSpawns,
|
||||||
|
bool DumpClothing,
|
||||||
int? LegacyStreamRadius,
|
int? LegacyStreamRadius,
|
||||||
bool RetailUi,
|
bool RetailUi,
|
||||||
string? AcDir,
|
string? AcDir,
|
||||||
|
|
@ -89,6 +90,7 @@ public sealed record RuntimeOptions(
|
||||||
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
||||||
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
||||||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||||
|
DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")),
|
||||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||||
// top of the quality preset's radii. Null when unset or invalid.
|
// top of the quality preset's radii. Null when unset or invalid.
|
||||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||||
|
|
|
||||||
613
src/AcDream.App/World/LiveEntityHydrationController.cs
Normal file
613
src/AcDream.App/World/LiveEntityHydrationController.cs
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
internal enum LiveProjectionPurpose
|
||||||
|
{
|
||||||
|
LogicalRegistration,
|
||||||
|
SpatialRecovery,
|
||||||
|
CreateSupersessionRecovery,
|
||||||
|
AppearanceMutation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DAT-backed projection boundary used by <see cref="LiveEntityHydrationController"/>.
|
||||||
|
/// The implementation owns mesh/animation/collision construction, but never
|
||||||
|
/// owns or indexes live identity.
|
||||||
|
/// </summary>
|
||||||
|
internal interface ILiveEntityProjectionMaterializer
|
||||||
|
{
|
||||||
|
bool TryMaterialize(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
WorldSession.EntitySpawn canonicalSpawn,
|
||||||
|
LiveProjectionPurpose purpose,
|
||||||
|
ulong expectedCreateIntegrationVersion,
|
||||||
|
LiveEntityAppearanceUpdateState? appearanceUpdate = null);
|
||||||
|
|
||||||
|
void ResetSessionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ILiveEntitySpawnRelationshipSink
|
||||||
|
{
|
||||||
|
void OnSpawn(WorldSession.EntitySpawn spawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface ILiveEntityReadyPublisher
|
||||||
|
{
|
||||||
|
bool Publish(LiveEntityRecord expectedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct LiveEntityReadyCandidate(
|
||||||
|
LiveEntityRecord Record,
|
||||||
|
ulong CreateIntegrationVersion);
|
||||||
|
|
||||||
|
internal interface ILiveEntityNetworkUpdateSink
|
||||||
|
{
|
||||||
|
void ApplySameGeneration(SameGenerationCreateObjectEvents events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-assignment construction seam between hydration and the Slice-4F
|
||||||
|
/// network-update owner. A session cannot publish CreateObject until this is
|
||||||
|
/// bound.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DeferredLiveEntityNetworkUpdateSink : ILiveEntityNetworkUpdateSink
|
||||||
|
{
|
||||||
|
private ILiveEntityNetworkUpdateSink? _inner;
|
||||||
|
|
||||||
|
public void Bind(ILiveEntityNetworkUpdateSink inner)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inner);
|
||||||
|
if (Interlocked.CompareExchange(ref _inner, inner, null) is not null)
|
||||||
|
throw new InvalidOperationException("The live-entity network sink is already bound.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) =>
|
||||||
|
(_inner ?? throw new InvalidOperationException(
|
||||||
|
"The live-entity network sink must be bound before a session starts."))
|
||||||
|
.ApplySameGeneration(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DelegateLiveEntityNetworkUpdateSink(
|
||||||
|
Action<SameGenerationCreateObjectEvents> apply) : ILiveEntityNetworkUpdateSink
|
||||||
|
{
|
||||||
|
private readonly Action<SameGenerationCreateObjectEvents> _apply =
|
||||||
|
apply ?? throw new ArgumentNullException(nameof(apply));
|
||||||
|
|
||||||
|
public void ApplySameGeneration(SameGenerationCreateObjectEvents events) =>
|
||||||
|
_apply(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct LiveEntityOriginInitialization(
|
||||||
|
bool IsKnown,
|
||||||
|
IReadOnlyList<uint> AlreadyLoadedLandblocks);
|
||||||
|
|
||||||
|
internal interface ILiveEntityWorldOriginCoordinator
|
||||||
|
{
|
||||||
|
bool IsKnown { get; }
|
||||||
|
LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the retail CreateObject integration transaction over the canonical
|
||||||
|
/// <see cref="LiveEntityRuntime"/>. It has no GUID dictionary: every callback
|
||||||
|
/// captures and revalidates the exact record supplied by the runtime.
|
||||||
|
///
|
||||||
|
/// Retail anchors: <c>SmartBox::HandleCreateObject @ 0x00454C80</c> and
|
||||||
|
/// <c>ACCObjectMaint::CreateObject @ 0x00558870</c>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveEntityHydrationController
|
||||||
|
{
|
||||||
|
private readonly LiveEntityRuntime _runtime;
|
||||||
|
private readonly ClientObjectTable _objects;
|
||||||
|
private readonly object _datLock;
|
||||||
|
private readonly ILiveEntityProjectionMaterializer _materializer;
|
||||||
|
private readonly ILiveEntitySpawnRelationshipSink _relationships;
|
||||||
|
private readonly ILiveEntityReadyPublisher _ready;
|
||||||
|
private readonly ILiveEntityWorldOriginCoordinator _origin;
|
||||||
|
private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
|
||||||
|
private readonly Action<uint, AcceptedPhysicsTimestamps> _publishTimestamps;
|
||||||
|
private readonly Func<uint> _playerGuid;
|
||||||
|
private readonly Action<string>? _diagnostic;
|
||||||
|
|
||||||
|
public LiveEntityHydrationController(
|
||||||
|
LiveEntityRuntime runtime,
|
||||||
|
ClientObjectTable objects,
|
||||||
|
object datLock,
|
||||||
|
ILiveEntityProjectionMaterializer materializer,
|
||||||
|
ILiveEntitySpawnRelationshipSink relationships,
|
||||||
|
ILiveEntityReadyPublisher ready,
|
||||||
|
ILiveEntityWorldOriginCoordinator origin,
|
||||||
|
ILiveEntityNetworkUpdateSink networkUpdates,
|
||||||
|
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||||
|
Func<uint> playerGuid,
|
||||||
|
Action<string>? diagnostic = null)
|
||||||
|
{
|
||||||
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||||
|
_materializer = materializer ?? throw new ArgumentNullException(nameof(materializer));
|
||||||
|
_relationships = relationships ?? throw new ArgumentNullException(nameof(relationships));
|
||||||
|
_ready = ready ?? throw new ArgumentNullException(nameof(ready));
|
||||||
|
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||||
|
_networkUpdates = networkUpdates ?? throw new ArgumentNullException(nameof(networkUpdates));
|
||||||
|
_publishTimestamps = publishTimestamps
|
||||||
|
?? throw new ArgumentNullException(nameof(publishTimestamps));
|
||||||
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
_diagnostic = diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnCreate(WorldSession.EntitySpawn spawn)
|
||||||
|
{
|
||||||
|
// DatCollection uses one mutable reader cursor shared with streaming.
|
||||||
|
// Registration, canonical reread, and projection construction remain
|
||||||
|
// one update-thread transaction under that lock.
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
LiveEntityRegistrationResult registration =
|
||||||
|
_runtime.RegisterLiveEntity(spawn);
|
||||||
|
InboundCreateResult result = registration.Inbound;
|
||||||
|
if (result.Disposition is
|
||||||
|
AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accepted retransmit can still be parked behind a retryable
|
||||||
|
// teardown tombstone. It owns no active record and therefore must
|
||||||
|
// not mutate retained qualities or route an update tail.
|
||||||
|
if (registration.Record is not { } record)
|
||||||
|
return;
|
||||||
|
ulong createIntegrationVersion = record.CreateIntegrationVersion;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_publishTimestamps(spawn.Guid, result.Timestamps);
|
||||||
|
if (_runtime.IsCurrentCreateIntegration(
|
||||||
|
record,
|
||||||
|
createIntegrationVersion)
|
||||||
|
&& ObjectTableWiring.ApplyEntitySpawn(
|
||||||
|
_objects,
|
||||||
|
spawn,
|
||||||
|
replaceGeneration: result.Disposition is
|
||||||
|
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration,
|
||||||
|
accepting: () => _runtime.IsCurrentCreateIntegration(
|
||||||
|
record,
|
||||||
|
createIntegrationVersion))
|
||||||
|
&& _runtime.IsCurrentCreateIntegration(
|
||||||
|
record,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
if (result.Disposition is
|
||||||
|
AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration)
|
||||||
|
{
|
||||||
|
if (registration.LogicalRegistrationCreated)
|
||||||
|
{
|
||||||
|
// A converged rollback tombstone can be repaired by
|
||||||
|
// a same-generation retransmit. Its accepted
|
||||||
|
// snapshot is already canonical, so construct from
|
||||||
|
// that snapshot rather than replaying a delta tail
|
||||||
|
// against owners that did not exist.
|
||||||
|
ProjectExact(
|
||||||
|
record,
|
||||||
|
result.Snapshot,
|
||||||
|
LiveProjectionPurpose.LogicalRegistration,
|
||||||
|
expectedCreateIntegrationVersion:
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (result.SameGenerationEvents is { } refresh)
|
||||||
|
_networkUpdates.ApplySameGeneration(refresh);
|
||||||
|
|
||||||
|
if (_runtime.IsCurrentCreateIntegration(
|
||||||
|
record,
|
||||||
|
createIntegrationVersion)
|
||||||
|
&& (record.CreateProjectionSynchronizationPending
|
||||||
|
|| !record.InitialHydrationCompleted))
|
||||||
|
{
|
||||||
|
ProjectExact(
|
||||||
|
record,
|
||||||
|
record.Snapshot,
|
||||||
|
record.CreateProjectionSynchronizationPending
|
||||||
|
? LiveProjectionPurpose.CreateSupersessionRecovery
|
||||||
|
: LiveProjectionPurpose.SpatialRecovery,
|
||||||
|
expectedCreateIntegrationVersion:
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ProjectExact(
|
||||||
|
record,
|
||||||
|
result.Snapshot,
|
||||||
|
LiveProjectionPurpose.LogicalRegistration,
|
||||||
|
expectedCreateIntegrationVersion:
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception applyFailure)
|
||||||
|
when (registration.PriorGenerationCleanupFailure is not null)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
$"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.",
|
||||||
|
registration.PriorGenerationCleanupFailure,
|
||||||
|
applyFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registration.PriorGenerationCleanupFailure is { } cleanupFailure)
|
||||||
|
{
|
||||||
|
throw new AggregateException(
|
||||||
|
$"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.",
|
||||||
|
cleanupFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reprojects retained live objects after their landblock is loaded. An
|
||||||
|
/// fully hydrated record only rebuckets. A retained partial projection
|
||||||
|
/// resumes its exact initial transaction without registering logical
|
||||||
|
/// mesh/script resources a second time.
|
||||||
|
/// </summary>
|
||||||
|
public void OnLandblockLoaded(uint loadedLandblockId)
|
||||||
|
{
|
||||||
|
if (_runtime.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
LiveEntityRecord[] records = _runtime.Records
|
||||||
|
.Where(record => (record.ServerGuid != _playerGuid()
|
||||||
|
|| !record.InitialHydrationCompleted
|
||||||
|
|| record.CreateProjectionSynchronizationPending)
|
||||||
|
&& record.ProjectionCellId != 0
|
||||||
|
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
|
||||||
|
&& record.Snapshot.SetupTableId is not null)
|
||||||
|
.ToArray();
|
||||||
|
if (records.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int projected = 0;
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
foreach (LiveEntityRecord record in records)
|
||||||
|
{
|
||||||
|
if (!_runtime.IsCurrentRecord(record))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (record.CreateProjectionSynchronizationPending)
|
||||||
|
{
|
||||||
|
if (ProjectExact(
|
||||||
|
record,
|
||||||
|
record.Snapshot,
|
||||||
|
LiveProjectionPurpose.CreateSupersessionRecovery))
|
||||||
|
{
|
||||||
|
projected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (record.WorldEntity is not null
|
||||||
|
&& record.InitialHydrationCompleted)
|
||||||
|
{
|
||||||
|
if (_runtime.RebucketLiveEntity(
|
||||||
|
record.ServerGuid,
|
||||||
|
record.ProjectionCellId))
|
||||||
|
{
|
||||||
|
projected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (ProjectExact(
|
||||||
|
record,
|
||||||
|
record.Snapshot,
|
||||||
|
LiveProjectionPurpose.SpatialRecovery))
|
||||||
|
{
|
||||||
|
projected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projected > 0)
|
||||||
|
{
|
||||||
|
_diagnostic?.Invoke(
|
||||||
|
$"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool EnsureWorldOrigin(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong positionAuthorityVersion,
|
||||||
|
WorldSession.EntitySpawn acceptedSpawn)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_runtime.IsCurrentPositionAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
positionAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
InitializeOriginAndRecoverLoaded(acceptedSpawn);
|
||||||
|
return _runtime.IsCurrentPositionAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
positionAuthorityVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool RecoverProjection(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong positionAuthorityVersion,
|
||||||
|
WorldSession.EntitySpawn acceptedSpawn)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
if (!_runtime.IsCurrentPositionAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
positionAuthorityVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProjectExact(
|
||||||
|
expectedRecord,
|
||||||
|
acceptedSpawn,
|
||||||
|
expectedRecord.CreateProjectionSynchronizationPending
|
||||||
|
? LiveProjectionPurpose.CreateSupersessionRecovery
|
||||||
|
: LiveProjectionPurpose.SpatialRecovery)
|
||||||
|
&& _runtime.IsCurrentPositionAuthority(
|
||||||
|
expectedRecord,
|
||||||
|
positionAuthorityVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ApplyAppearance(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
WorldSession.EntitySpawn acceptedSpawn,
|
||||||
|
LiveEntityAppearanceUpdateState? appearanceUpdate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
lock (_datLock)
|
||||||
|
{
|
||||||
|
return ProjectExact(
|
||||||
|
expectedRecord,
|
||||||
|
acceptedSpawn,
|
||||||
|
appearanceUpdate is null
|
||||||
|
? LiveProjectionPurpose.SpatialRecovery
|
||||||
|
: LiveProjectionPurpose.AppearanceMutation,
|
||||||
|
appearanceUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
|
||||||
|
{
|
||||||
|
if (!PublishReady(
|
||||||
|
candidate.Record,
|
||||||
|
candidate.CreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !candidate.Record.CreateProjectionSynchronizationPending
|
||||||
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
|
candidate.Record,
|
||||||
|
candidate.CreateIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetSessionState() => _materializer.ResetSessionState();
|
||||||
|
|
||||||
|
private bool ProjectExact(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
WorldSession.EntitySpawn acceptedSpawn,
|
||||||
|
LiveProjectionPurpose purpose,
|
||||||
|
LiveEntityAppearanceUpdateState? appearanceUpdate = null,
|
||||||
|
ulong? expectedCreateIntegrationVersion = null)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ulong createIntegrationVersion = expectedCreateIntegrationVersion
|
||||||
|
?? expectedRecord.CreateIntegrationVersion;
|
||||||
|
if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery
|
||||||
|
&& !_runtime.TryMarkCreateProjectionSynchronizationPending(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!_runtime.TryBeginProjectionHydration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool retry;
|
||||||
|
bool result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = ProjectExactOnce(
|
||||||
|
expectedRecord,
|
||||||
|
acceptedSpawn,
|
||||||
|
purpose,
|
||||||
|
appearanceUpdate,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
retry = _runtime.EndProjectionHydration(expectedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!retry || !_runtime.IsCurrentRecord(expectedRecord))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// A fresher same-incarnation CreateObject arrived while this
|
||||||
|
// transaction owned the hydration edge. Resume in-place from the
|
||||||
|
// newest canonical snapshot; logical resources remain registered.
|
||||||
|
acceptedSpawn = expectedRecord.Snapshot;
|
||||||
|
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
|
||||||
|
appearanceUpdate = null;
|
||||||
|
expectedCreateIntegrationVersion =
|
||||||
|
expectedRecord.CreateIntegrationVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ProjectExactOnce(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
WorldSession.EntitySpawn acceptedSpawn,
|
||||||
|
LiveProjectionPurpose purpose,
|
||||||
|
LiveEntityAppearanceUpdateState? appearanceUpdate,
|
||||||
|
ulong createIntegrationVersion)
|
||||||
|
{
|
||||||
|
_relationships.OnSpawn(acceptedSpawn);
|
||||||
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A queued Parent event can synchronously mutate the canonical
|
||||||
|
// Position/Parent snapshot. Never continue from the stale argument.
|
||||||
|
WorldSession.EntitySpawn canonicalSpawn = expectedRecord.Snapshot;
|
||||||
|
InitializeOriginAndRecoverLoaded(canonicalSpawn);
|
||||||
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion)
|
||||||
|
|| !_origin.IsKnown)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retail's same-instance CreateObject branch completes a POSITION_TS
|
||||||
|
// with no world Position through DoParentEvent or DoPickupEvent, not
|
||||||
|
// through CPhysicsObj::enter_world. The relationship/update owners
|
||||||
|
// above have already committed that cell-less state. An attached
|
||||||
|
// child discharges the obligation at its exact EntityReady edge;
|
||||||
|
// pickup completes here after exact projection withdrawal.
|
||||||
|
if (canonicalSpawn.Position is null)
|
||||||
|
{
|
||||||
|
if (canonicalSpawn.ParentGuid is not null and not 0)
|
||||||
|
{
|
||||||
|
if (expectedRecord.InitialHydrationCompleted
|
||||||
|
&& !expectedRecord.CreateProjectionSynchronizationPending)
|
||||||
|
{
|
||||||
|
return _runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedRecord.WorldEntity is null
|
||||||
|
|| expectedRecord.ProjectionKind is not
|
||||||
|
LiveEntityProjectionKind.Attached
|
||||||
|
|| !expectedRecord.IsSpatiallyProjected
|
||||||
|
|| !PublishReady(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !expectedRecord.CreateProjectionSynchronizationPending
|
||||||
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedRecord.FullCellId != 0
|
||||||
|
|| expectedRecord.IsSpatiallyProjected)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedRecord.WorldEntity is null)
|
||||||
|
{
|
||||||
|
// A pickup can supersede initial hydration before a render
|
||||||
|
// projection exists. The logical snapshot/table state is
|
||||||
|
// already canonical; ready remains false so a later world
|
||||||
|
// Position still constructs and publishes the first owner.
|
||||||
|
return !expectedRecord.CreateProjectionSynchronizationPending
|
||||||
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PublishReady(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !expectedRecord.CreateProjectionSynchronizationPending
|
||||||
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool materialized = _materializer.TryMaterialize(
|
||||||
|
expectedRecord,
|
||||||
|
expectedRecord.Snapshot,
|
||||||
|
purpose,
|
||||||
|
createIntegrationVersion,
|
||||||
|
appearanceUpdate);
|
||||||
|
if (!materialized
|
||||||
|
|| !_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion)
|
||||||
|
|| purpose is LiveProjectionPurpose.AppearanceMutation)
|
||||||
|
{
|
||||||
|
return materialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PublishReady(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return purpose is not LiveProjectionPurpose.CreateSupersessionRecovery
|
||||||
|
|| _runtime.TryCompleteCreateProjectionSynchronization(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool PublishReady(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion)
|
||||||
|
{
|
||||||
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
|| !_ready.Publish(expectedRecord)
|
||||||
|
|| !_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _runtime.TryMarkInitialHydrationCompleted(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion)
|
||||||
|
&& _runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeOriginAndRecoverLoaded(
|
||||||
|
WorldSession.EntitySpawn canonicalSpawn)
|
||||||
|
{
|
||||||
|
LiveEntityOriginInitialization initialization =
|
||||||
|
_origin.TryInitialize(canonicalSpawn);
|
||||||
|
if (!initialization.IsKnown)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Preserve SmartBox's blocking world-entry ordering: already-resident
|
||||||
|
// cells are made available before the outer CreateObject completes.
|
||||||
|
for (int i = 0; i < initialization.AlreadyLoadedLandblocks.Count; i++)
|
||||||
|
OnLandblockLoaded(initialization.AlreadyLoadedLandblocks[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/AcDream.App/World/LiveEntityHydrationPorts.cs
Normal file
150
src/AcDream.App/World/LiveEntityHydrationPorts.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
internal sealed class DelegateLiveEntitySpawnRelationshipSink(
|
||||||
|
Action<WorldSession.EntitySpawn> onSpawn) : ILiveEntitySpawnRelationshipSink
|
||||||
|
{
|
||||||
|
private readonly Action<WorldSession.EntitySpawn> _onSpawn =
|
||||||
|
onSpawn ?? throw new ArgumentNullException(nameof(onSpawn));
|
||||||
|
|
||||||
|
public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
|
||||||
|
{
|
||||||
|
private readonly LiveEntityRuntime _runtime;
|
||||||
|
private readonly Func<LiveEntityRecord, bool> _prepareEffects;
|
||||||
|
private readonly Func<LiveEntityRecord, bool> _presentState;
|
||||||
|
private readonly Func<LiveEntityRecord, bool> _replayEffects;
|
||||||
|
|
||||||
|
public LiveEntityReadyPublisher(
|
||||||
|
LiveEntityRuntime runtime,
|
||||||
|
EntityEffectController effects,
|
||||||
|
LiveEntityPresentationController presentation)
|
||||||
|
: this(
|
||||||
|
runtime,
|
||||||
|
record => effects.PrepareLiveEntityOwner(record.ServerGuid),
|
||||||
|
record => presentation.OnLiveEntityReady(record.ServerGuid),
|
||||||
|
record => effects.ReplayPendingForLiveEntity(record.ServerGuid))
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(effects);
|
||||||
|
ArgumentNullException.ThrowIfNull(presentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal LiveEntityReadyPublisher(
|
||||||
|
LiveEntityRuntime runtime,
|
||||||
|
Func<LiveEntityRecord, bool> prepareEffects,
|
||||||
|
Func<LiveEntityRecord, bool> presentState,
|
||||||
|
Func<LiveEntityRecord, bool> replayEffects)
|
||||||
|
{
|
||||||
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||||
|
_prepareEffects = prepareEffects
|
||||||
|
?? throw new ArgumentNullException(nameof(prepareEffects));
|
||||||
|
_presentState = presentState
|
||||||
|
?? throw new ArgumentNullException(nameof(presentState));
|
||||||
|
_replayEffects = replayEffects
|
||||||
|
?? throw new ArgumentNullException(nameof(replayEffects));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Publish(LiveEntityRecord expectedRecord)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion;
|
||||||
|
if (!_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion)
|
||||||
|
|| !_prepareEffects(expectedRecord)
|
||||||
|
|| !_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!_presentState(expectedRecord)
|
||||||
|
|| !_runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _replayEffects(expectedRecord)
|
||||||
|
&& _runtime.IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
createIntegrationVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the first accepted local-player Position into the shared streaming
|
||||||
|
/// origin. Retail <c>SmartBox::UseTime @ 0x00455410</c> blocks completion until
|
||||||
|
/// destination cells are ready; already-resident landblocks are returned to
|
||||||
|
/// hydration for the same synchronous recovery edge.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginCoordinator
|
||||||
|
{
|
||||||
|
private readonly LiveWorldOriginState _origin;
|
||||||
|
private readonly StreamingController _streaming;
|
||||||
|
private readonly GpuWorldState _worldState;
|
||||||
|
private readonly WorldRevealCoordinator _worldReveal;
|
||||||
|
private readonly Func<uint> _playerGuid;
|
||||||
|
private readonly Func<uint, bool> _isSealedDungeonCell;
|
||||||
|
private readonly Action<string>? _diagnostic;
|
||||||
|
|
||||||
|
public LiveEntityWorldOriginCoordinator(
|
||||||
|
LiveWorldOriginState origin,
|
||||||
|
StreamingController streaming,
|
||||||
|
GpuWorldState worldState,
|
||||||
|
WorldRevealCoordinator worldReveal,
|
||||||
|
Func<uint> playerGuid,
|
||||||
|
Func<uint, bool> isSealedDungeonCell,
|
||||||
|
Action<string>? diagnostic = null)
|
||||||
|
{
|
||||||
|
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||||
|
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
||||||
|
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||||
|
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
|
||||||
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
_isSealedDungeonCell = isSealedDungeonCell
|
||||||
|
?? throw new ArgumentNullException(nameof(isSealedDungeonCell));
|
||||||
|
_diagnostic = diagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsKnown => _origin.IsKnown;
|
||||||
|
|
||||||
|
public LiveEntityOriginInitialization TryInitialize(
|
||||||
|
WorldSession.EntitySpawn spawn)
|
||||||
|
{
|
||||||
|
if (spawn.Guid != _playerGuid()
|
||||||
|
|| spawn.Position is not { } position)
|
||||||
|
{
|
||||||
|
return new(_origin.IsKnown, Array.Empty<uint>());
|
||||||
|
}
|
||||||
|
|
||||||
|
int lbX = (int)((position.LandblockId >> 24) & 0xFFu);
|
||||||
|
int lbY = (int)((position.LandblockId >> 16) & 0xFFu);
|
||||||
|
int oldCenterX = _origin.CenterX;
|
||||||
|
int oldCenterY = _origin.CenterY;
|
||||||
|
if (!_origin.TryInitialize(lbX, lbY))
|
||||||
|
return new(true, Array.Empty<uint>());
|
||||||
|
|
||||||
|
if (lbX != oldCenterX || lbY != oldCenterY)
|
||||||
|
{
|
||||||
|
_diagnostic?.Invoke(
|
||||||
|
$"live: first player position — recentering streaming from ({oldCenterX},{oldCenterY}) "
|
||||||
|
+ $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_streaming.InitializeKnownLoginCenter(
|
||||||
|
lbX,
|
||||||
|
lbY,
|
||||||
|
isSealedDungeon: _isSealedDungeonCell(position.LandblockId));
|
||||||
|
_worldReveal.Begin(WorldRevealKind.Login);
|
||||||
|
|
||||||
|
return new(
|
||||||
|
true,
|
||||||
|
_worldState.LoadedLandblockIds.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -258,7 +258,32 @@ public sealed class LiveEntityRecord
|
||||||
internal ulong VectorAuthorityVersion { get; private set; }
|
internal ulong VectorAuthorityVersion { get; private set; }
|
||||||
internal ulong VelocityAuthorityVersion { get; private set; }
|
internal ulong VelocityAuthorityVersion { get; private set; }
|
||||||
internal ulong MovementAuthorityVersion { get; private set; }
|
internal ulong MovementAuthorityVersion { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Advances for every accepted CreateObject affecting this incarnation,
|
||||||
|
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
||||||
|
/// it to distinguish two transactions that intentionally share identity.
|
||||||
|
/// </summary>
|
||||||
|
internal ulong CreateIntegrationVersion { get; private set; } = 1UL;
|
||||||
public bool WorldSpawnPublished { get; internal set; }
|
public bool WorldSpawnPublished { get; internal set; }
|
||||||
|
/// <summary>
|
||||||
|
/// True only after the incarnation's projection, collision/animation
|
||||||
|
/// components, effect owner, state presentation, and pending-effect replay
|
||||||
|
/// have crossed the initial ready barrier. A non-null
|
||||||
|
/// <see cref="WorldEntity"/> alone is not sufficient: failures after
|
||||||
|
/// resource registration retain the same projection for exact retry.
|
||||||
|
/// </summary>
|
||||||
|
public bool InitialHydrationCompleted { get; internal set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A fresher same-incarnation CreateObject interrupted projection after
|
||||||
|
/// logical ownership had already been established. The retained entity
|
||||||
|
/// must replay the newest canonical appearance/current snapshot/animation
|
||||||
|
/// before ordinary rebucketing can resume. This obligation deliberately
|
||||||
|
/// survives a failed recovery attempt.
|
||||||
|
/// </summary>
|
||||||
|
internal bool CreateProjectionSynchronizationPending { get; set; }
|
||||||
|
internal bool ProjectionHydrationInProgress { get; set; }
|
||||||
|
internal ulong ProjectionHydrationCreateVersion { get; set; }
|
||||||
|
internal bool ProjectionHydrationRetryRequested { get; set; }
|
||||||
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
||||||
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
||||||
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
||||||
|
|
@ -320,6 +345,7 @@ public sealed class LiveEntityRecord
|
||||||
VectorAuthorityVersion++;
|
VectorAuthorityVersion++;
|
||||||
VelocityAuthorityVersion++;
|
VelocityAuthorityVersion++;
|
||||||
MovementAuthorityVersion++;
|
MovementAuthorityVersion++;
|
||||||
|
CreateIntegrationVersion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SetChildNoDraw(bool noDraw)
|
internal void SetChildNoDraw(bool noDraw)
|
||||||
|
|
@ -2031,6 +2057,111 @@ public sealed class LiveEntityRuntime
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool TryMarkInitialHydrationCompleted(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!IsCurrentRecord(expectedRecord)
|
||||||
|
|| expectedRecord.CreateIntegrationVersion != expectedCreateIntegrationVersion
|
||||||
|
|| expectedRecord.WorldEntity is null
|
||||||
|
|| !expectedRecord.ResourcesRegistered)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.InitialHydrationCompleted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryMarkCreateProjectionSynchronizationPending(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryCompleteCreateProjectionSynchronization(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!IsCurrentCreateIntegration(
|
||||||
|
expectedRecord,
|
||||||
|
expectedCreateIntegrationVersion))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.CreateProjectionSynchronizationPending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryBeginProjectionHydration(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong? expectedCreateIntegrationVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
if (!IsCurrentRecord(expectedRecord)
|
||||||
|
|| (expectedCreateIntegrationVersion is { } expected
|
||||||
|
&& expectedRecord.CreateIntegrationVersion != expected))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedRecord.ProjectionHydrationInProgress)
|
||||||
|
{
|
||||||
|
if (expectedRecord.CreateIntegrationVersion
|
||||||
|
!= expectedRecord.ProjectionHydrationCreateVersion)
|
||||||
|
{
|
||||||
|
expectedRecord.ProjectionHydrationRetryRequested = true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRecord.ProjectionHydrationInProgress = true;
|
||||||
|
expectedRecord.ProjectionHydrationCreateVersion =
|
||||||
|
expectedRecord.CreateIntegrationVersion;
|
||||||
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool EndProjectionHydration(LiveEntityRecord expectedRecord)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||||
|
bool retry = IsCurrentRecord(expectedRecord)
|
||||||
|
&& (expectedRecord.ProjectionHydrationRetryRequested
|
||||||
|
|| expectedRecord.CreateIntegrationVersion
|
||||||
|
!= expectedRecord.ProjectionHydrationCreateVersion);
|
||||||
|
if (retry)
|
||||||
|
{
|
||||||
|
// Persist the exact replay obligation at the point version drift
|
||||||
|
// is observed. The stale projection body may be unwinding through
|
||||||
|
// an exception and therefore never reach the controller's next
|
||||||
|
// retry-loop iteration.
|
||||||
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
||||||
|
}
|
||||||
|
expectedRecord.ProjectionHydrationInProgress = false;
|
||||||
|
expectedRecord.ProjectionHydrationCreateVersion = 0UL;
|
||||||
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
||||||
|
return retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool IsCurrentCreateIntegration(
|
||||||
|
LiveEntityRecord expectedRecord,
|
||||||
|
ulong expectedCreateIntegrationVersion) =>
|
||||||
|
IsCurrentRecord(expectedRecord)
|
||||||
|
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
if (_isClearing || _isRegisteringResources)
|
if (_isClearing || _isRegisteringResources)
|
||||||
|
|
@ -2586,6 +2717,11 @@ public sealed class LiveEntityRuntime
|
||||||
record.IsSpatiallyProjected = false;
|
record.IsSpatiallyProjected = false;
|
||||||
record.IsSpatiallyVisible = false;
|
record.IsSpatiallyVisible = false;
|
||||||
record.WorldSpawnPublished = false;
|
record.WorldSpawnPublished = false;
|
||||||
|
record.InitialHydrationCompleted = false;
|
||||||
|
record.CreateProjectionSynchronizationPending = false;
|
||||||
|
record.ProjectionHydrationInProgress = false;
|
||||||
|
record.ProjectionHydrationCreateVersion = 0UL;
|
||||||
|
record.ProjectionHydrationRetryRequested = false;
|
||||||
record.WorldEntity = null;
|
record.WorldEntity = null;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
|
||||||
|
|
@ -101,16 +101,31 @@ public static class ObjectTableWiring
|
||||||
/// owning runtime accepts its physics generation. Internal for a
|
/// owning runtime accepts its physics generation. Internal for a
|
||||||
/// socket-free conformance test of the subscription body.
|
/// socket-free conformance test of the subscription body.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void ApplyEntitySpawn(
|
public static bool ApplyEntitySpawn(
|
||||||
ClientObjectTable table,
|
ClientObjectTable table,
|
||||||
WorldSession.EntitySpawn spawn,
|
WorldSession.EntitySpawn spawn,
|
||||||
bool replaceGeneration = false)
|
bool replaceGeneration = false,
|
||||||
|
Func<bool>? accepting = null)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(table);
|
||||||
|
if (accepting?.Invoke() == false)
|
||||||
|
return false;
|
||||||
|
|
||||||
WeenieData data = ToWeenieData(spawn);
|
WeenieData data = ToWeenieData(spawn);
|
||||||
if (replaceGeneration)
|
if (replaceGeneration)
|
||||||
table.ReplaceGeneration(data, spawn.InstanceSequence);
|
{
|
||||||
|
return table.ReplaceGeneration(
|
||||||
|
data,
|
||||||
|
spawn.InstanceSequence,
|
||||||
|
accepting) is not null;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
table.Ingest(data);
|
table.Ingest(data);
|
||||||
|
|
||||||
|
// Ingest publishes ObjectAdded/ObjectUpdated synchronously. A nested
|
||||||
|
// replacement wins; report the invalidated outer application so its
|
||||||
|
// remaining CreateObject tail cannot run.
|
||||||
|
return accepting?.Invoke() != false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -576,7 +576,10 @@ public sealed class ClientObjectTable
|
||||||
/// Atomically replaces the retained qualities for a newer server object
|
/// Atomically replaces the retained qualities for a newer server object
|
||||||
/// incarnation while publishing a generation-specific teardown event.
|
/// incarnation while publishing a generation-specific teardown event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
|
public ClientObject? ReplaceGeneration(
|
||||||
|
WeenieData data,
|
||||||
|
ushort generation,
|
||||||
|
Func<bool>? canInstallReplacement = null)
|
||||||
{
|
{
|
||||||
RemoveCore(
|
RemoveCore(
|
||||||
data.Guid,
|
data.Guid,
|
||||||
|
|
@ -584,6 +587,13 @@ public sealed class ClientObjectTable
|
||||||
generation,
|
generation,
|
||||||
notifyObjectRemoved: true);
|
notifyObjectRemoved: true);
|
||||||
|
|
||||||
|
// Removal observers may synchronously accept a newer CreateObject for
|
||||||
|
// this GUID. The outer generation must not resume and merge its stale
|
||||||
|
// qualities into that replacement. The authoritative live-identity
|
||||||
|
// owner supplies the exact-incarnation predicate.
|
||||||
|
if (canInstallReplacement?.Invoke() == false)
|
||||||
|
return null;
|
||||||
|
|
||||||
return Ingest(data);
|
return Ingest(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Physics.Motion;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class LiveEntityCreateSupersessionRecoveryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Recovery_RepublishesAppearanceThenCurrentSnapshotThenReplacesAnimation()
|
||||||
|
{
|
||||||
|
var operations = new List<string>();
|
||||||
|
var resources = new CountingResources();
|
||||||
|
var runtime = Runtime(resources);
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
ulong version = record.CreateIntegrationVersion;
|
||||||
|
ulong installedAnimationVersion = 0;
|
||||||
|
|
||||||
|
bool recovered = LiveEntityCreateSupersessionRecovery.TryApply(
|
||||||
|
runtime,
|
||||||
|
record,
|
||||||
|
version,
|
||||||
|
captureAppearance: () =>
|
||||||
|
{
|
||||||
|
operations.Add("capture");
|
||||||
|
return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null);
|
||||||
|
},
|
||||||
|
publishAppearance: _ =>
|
||||||
|
{
|
||||||
|
operations.Add("appearance");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
publishCurrentSnapshot: _ => operations.Add("current"),
|
||||||
|
synchronizeAnimation: _ =>
|
||||||
|
{
|
||||||
|
operations.Add("animation");
|
||||||
|
installedAnimationVersion = version;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(recovered);
|
||||||
|
Assert.Equal(["capture", "appearance", "current", "animation"], operations);
|
||||||
|
Assert.Equal(version, installedAnimationVersion);
|
||||||
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
|
Assert.Equal(0, resources.UnregisterCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recovery_CreateVersionAdvanceDuringAppearance_StopsLaterOwners()
|
||||||
|
{
|
||||||
|
var operations = new List<string>();
|
||||||
|
var resources = new CountingResources();
|
||||||
|
var runtime = Runtime(resources);
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
ulong staleVersion = record.CreateIntegrationVersion;
|
||||||
|
|
||||||
|
bool recovered = LiveEntityCreateSupersessionRecovery.TryApply(
|
||||||
|
runtime,
|
||||||
|
record,
|
||||||
|
staleVersion,
|
||||||
|
captureAppearance: () =>
|
||||||
|
{
|
||||||
|
operations.Add("capture");
|
||||||
|
return new LiveEntityAppearanceUpdateState(record.WorldEntity!, null);
|
||||||
|
},
|
||||||
|
publishAppearance: _ =>
|
||||||
|
{
|
||||||
|
operations.Add("appearance");
|
||||||
|
runtime.RegisterLiveEntity(Spawn() with { Name = "fresher" });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
publishCurrentSnapshot: _ => operations.Add("current"),
|
||||||
|
synchronizeAnimation: _ => operations.Add("animation"));
|
||||||
|
|
||||||
|
Assert.False(recovered);
|
||||||
|
Assert.Equal(["capture", "appearance"], operations);
|
||||||
|
Assert.Same(record, AssertRecord(runtime));
|
||||||
|
Assert.Equal(staleVersion + 1, record.CreateIntegrationVersion);
|
||||||
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
|
Assert.Equal(0, resources.UnregisterCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public void Recovery_FailureAtEachPublicationStage_CanReplayOnRetainedOwner(
|
||||||
|
int failingStage)
|
||||||
|
{
|
||||||
|
var resources = new CountingResources();
|
||||||
|
var runtime = Runtime(resources);
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
ulong version = record.CreateIntegrationVersion;
|
||||||
|
ulong appearanceVersion = 1;
|
||||||
|
ulong currentVersion = 1;
|
||||||
|
ulong animationVersion = 1;
|
||||||
|
bool fail = true;
|
||||||
|
void FailOnce(int stage)
|
||||||
|
{
|
||||||
|
if (fail && failingStage == stage)
|
||||||
|
{
|
||||||
|
fail = false;
|
||||||
|
throw new InvalidOperationException($"fixture stage {stage} failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Apply() => LiveEntityCreateSupersessionRecovery.TryApply(
|
||||||
|
runtime,
|
||||||
|
record,
|
||||||
|
version,
|
||||||
|
captureAppearance: () =>
|
||||||
|
new LiveEntityAppearanceUpdateState(record.WorldEntity!, null),
|
||||||
|
publishAppearance: _ =>
|
||||||
|
{
|
||||||
|
appearanceVersion = version;
|
||||||
|
FailOnce(0);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
publishCurrentSnapshot: _ =>
|
||||||
|
{
|
||||||
|
currentVersion = version;
|
||||||
|
FailOnce(1);
|
||||||
|
},
|
||||||
|
synchronizeAnimation: _ =>
|
||||||
|
{
|
||||||
|
animationVersion = version;
|
||||||
|
FailOnce(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => Apply());
|
||||||
|
Assert.True(Apply());
|
||||||
|
|
||||||
|
Assert.Equal(version, appearanceVersion);
|
||||||
|
Assert.Equal(version, currentVersion);
|
||||||
|
Assert.Equal(version, animationVersion);
|
||||||
|
Assert.Same(record, AssertRecord(runtime));
|
||||||
|
Assert.Equal(1, resources.RegisterCount);
|
||||||
|
Assert.Equal(0, resources.UnregisterCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompletedOwner_PreservesSequencerSinkAndPendingQueue()
|
||||||
|
{
|
||||||
|
var runtime = Runtime(new CountingResources());
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
record.InitialHydrationCompleted = true;
|
||||||
|
(Setup setup, MotionTable table, Loader loader) = MotionAssets();
|
||||||
|
AnimationSequencer sequencer = SpawnMotionInitializer.Create(
|
||||||
|
setup,
|
||||||
|
table,
|
||||||
|
loader,
|
||||||
|
Wire(Ready));
|
||||||
|
var animation = AnimationState(record.WorldEntity!, setup, sequencer);
|
||||||
|
var body = MotionBody();
|
||||||
|
var interpreter = new MotionInterpreter(body);
|
||||||
|
var sink = new MotionTableDispatchSink(sequencer);
|
||||||
|
interpreter.DefaultSink = sink;
|
||||||
|
sequencer.MotionDoneTarget = interpreter.MotionDone;
|
||||||
|
interpreter.DoMotion(Walk, new MovementParameters());
|
||||||
|
int pendingBefore = sequencer.Manager.PendingAnimations.Count();
|
||||||
|
|
||||||
|
bool reinitialized = LiveEntityCreateAnimationSynchronization
|
||||||
|
.TrySynchronizeInterruptedInitialOwner(
|
||||||
|
record,
|
||||||
|
animation,
|
||||||
|
canonicalAnimation: null,
|
||||||
|
canonicalLowFrame: 0,
|
||||||
|
canonicalHighFrame: 0,
|
||||||
|
canonicalFramerate: 0f,
|
||||||
|
motionTable: table,
|
||||||
|
wireState: Wire(Ready));
|
||||||
|
|
||||||
|
Assert.False(reinitialized);
|
||||||
|
Assert.Same(sequencer, animation.Sequencer);
|
||||||
|
Assert.Equal(pendingBefore, sequencer.Manager.PendingAnimations.Count());
|
||||||
|
Assert.True(interpreter.MotionsPending());
|
||||||
|
Assert.True(sink.ApplyMotion(Ready, 1f));
|
||||||
|
Assert.Equal(Ready, sequencer.CurrentMotion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompletedLegacyOwner_PreservesAnimationAndCurrentPhase()
|
||||||
|
{
|
||||||
|
var runtime = Runtime(new CountingResources());
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
record.InitialHydrationCompleted = true;
|
||||||
|
var setup = new Setup();
|
||||||
|
var retainedAnimation = new Animation();
|
||||||
|
var canonicalAnimation = new Animation();
|
||||||
|
var state = new LiveEntityAnimationState
|
||||||
|
{
|
||||||
|
Entity = record.WorldEntity!,
|
||||||
|
Setup = setup,
|
||||||
|
Animation = retainedAnimation,
|
||||||
|
LowFrame = 2,
|
||||||
|
HighFrame = 11,
|
||||||
|
Framerate = 12f,
|
||||||
|
Scale = 1f,
|
||||||
|
PartTemplate = [],
|
||||||
|
PartAvailability = [],
|
||||||
|
CurrFrame = 7.25f,
|
||||||
|
Sequencer = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
bool synchronized = LiveEntityCreateAnimationSynchronization
|
||||||
|
.TrySynchronizeInterruptedInitialOwner(
|
||||||
|
record,
|
||||||
|
state,
|
||||||
|
canonicalAnimation,
|
||||||
|
canonicalLowFrame: 0,
|
||||||
|
canonicalHighFrame: 4,
|
||||||
|
canonicalFramerate: 30f,
|
||||||
|
motionTable: null,
|
||||||
|
wireState: null);
|
||||||
|
|
||||||
|
Assert.False(synchronized);
|
||||||
|
Assert.Same(retainedAnimation, state.Animation);
|
||||||
|
Assert.Equal(2, state.LowFrame);
|
||||||
|
Assert.Equal(11, state.HighFrame);
|
||||||
|
Assert.Equal(12f, state.Framerate);
|
||||||
|
Assert.Equal(7.25f, state.CurrFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterruptedInitialOwner_ReinitializesSameSequencerAndDrainsPairedQueues()
|
||||||
|
{
|
||||||
|
var runtime = Runtime(new CountingResources());
|
||||||
|
LiveEntityRecord record = RegisterAndMaterialize(runtime);
|
||||||
|
Assert.False(record.InitialHydrationCompleted);
|
||||||
|
(Setup setup, MotionTable table, Loader loader) = MotionAssets();
|
||||||
|
AnimationSequencer sequencer = SpawnMotionInitializer.Create(
|
||||||
|
setup,
|
||||||
|
table,
|
||||||
|
loader,
|
||||||
|
Wire(Ready));
|
||||||
|
var animation = AnimationState(record.WorldEntity!, setup, sequencer);
|
||||||
|
var body = MotionBody();
|
||||||
|
var interpreter = new MotionInterpreter(body);
|
||||||
|
var sink = new MotionTableDispatchSink(sequencer);
|
||||||
|
interpreter.DefaultSink = sink;
|
||||||
|
sequencer.MotionDoneTarget = interpreter.MotionDone;
|
||||||
|
interpreter.DoMotion(Walk, new MovementParameters());
|
||||||
|
Assert.True(interpreter.MotionsPending());
|
||||||
|
Assert.NotEmpty(sequencer.Manager.PendingAnimations);
|
||||||
|
|
||||||
|
bool reinitialized = LiveEntityCreateAnimationSynchronization
|
||||||
|
.TrySynchronizeInterruptedInitialOwner(
|
||||||
|
record,
|
||||||
|
animation,
|
||||||
|
canonicalAnimation: null,
|
||||||
|
canonicalLowFrame: 0,
|
||||||
|
canonicalHighFrame: 0,
|
||||||
|
canonicalFramerate: 0f,
|
||||||
|
motionTable: table,
|
||||||
|
wireState: Wire(Ready));
|
||||||
|
|
||||||
|
Assert.True(reinitialized);
|
||||||
|
Assert.Same(sequencer, animation.Sequencer);
|
||||||
|
Assert.False(interpreter.MotionsPending());
|
||||||
|
Assert.Empty(sequencer.Manager.PendingAnimations);
|
||||||
|
Assert.Equal(Ready, sequencer.CurrentMotion);
|
||||||
|
Assert.True(sink.ApplyMotion(Walk, 1f));
|
||||||
|
Assert.Equal(Walk, sequencer.CurrentMotion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint Guid = 0x70000061u;
|
||||||
|
private const uint Cell = 0x01010001u;
|
||||||
|
private const uint NonCombat = 0x8000003Du;
|
||||||
|
private const uint Ready = 0x41000003u;
|
||||||
|
private const uint Walk = 0x45000005u;
|
||||||
|
private const uint ReadyAnimation = 0x03000001u;
|
||||||
|
private const uint WalkAnimation = 0x03000002u;
|
||||||
|
|
||||||
|
private static LiveEntityRuntime Runtime(CountingResources resources)
|
||||||
|
{
|
||||||
|
var spatial = new GpuWorldState();
|
||||||
|
spatial.AddLandblock(new LoadedLandblock(
|
||||||
|
0x0101FFFFu,
|
||||||
|
new DatReaderWriter.DBObjs.LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>()));
|
||||||
|
return new LiveEntityRuntime(
|
||||||
|
spatial,
|
||||||
|
resources,
|
||||||
|
new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveEntityRecord RegisterAndMaterialize(LiveEntityRuntime runtime)
|
||||||
|
{
|
||||||
|
LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn()).Record!;
|
||||||
|
runtime.MaterializeLiveEntity(
|
||||||
|
Guid,
|
||||||
|
Cell,
|
||||||
|
id => new WorldEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ServerGuid = Guid,
|
||||||
|
SourceGfxObjOrSetupId = 0x02000001u,
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = Quaternion.Identity,
|
||||||
|
MeshRefs = [],
|
||||||
|
ParentCellId = Cell,
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveEntityRecord AssertRecord(LiveEntityRuntime runtime)
|
||||||
|
{
|
||||||
|
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn() => new(
|
||||||
|
Guid,
|
||||||
|
new CreateObject.ServerPosition(
|
||||||
|
Cell,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f),
|
||||||
|
0x02000001u,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
BasePaletteId: null,
|
||||||
|
ObjScale: null,
|
||||||
|
Name: "supersession fixture",
|
||||||
|
ItemType: null,
|
||||||
|
MotionState: null,
|
||||||
|
MotionTableId: null,
|
||||||
|
InstanceSequence: 1);
|
||||||
|
|
||||||
|
private static LiveEntityAnimationState AnimationState(
|
||||||
|
WorldEntity entity,
|
||||||
|
Setup setup,
|
||||||
|
AnimationSequencer sequencer) => new()
|
||||||
|
{
|
||||||
|
Entity = entity,
|
||||||
|
Setup = setup,
|
||||||
|
Animation = new Animation(),
|
||||||
|
LowFrame = 0,
|
||||||
|
HighFrame = 0,
|
||||||
|
Framerate = 0f,
|
||||||
|
Scale = 1f,
|
||||||
|
PartTemplate = [],
|
||||||
|
PartAvailability = [],
|
||||||
|
Sequencer = sequencer,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PhysicsBody MotionBody() => new()
|
||||||
|
{
|
||||||
|
InWorld = true,
|
||||||
|
State = PhysicsStateFlags.ReportCollisions,
|
||||||
|
TransientState = TransientStateFlags.Contact
|
||||||
|
| TransientStateFlags.OnWalkable
|
||||||
|
| TransientStateFlags.Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static CreateObject.ServerMotionState Wire(uint command) => new(
|
||||||
|
Stance: (ushort)(NonCombat & 0xFFFFu),
|
||||||
|
ForwardCommand: (ushort)(command & 0xFFFFu));
|
||||||
|
|
||||||
|
private static (Setup Setup, MotionTable Table, Loader Loader) MotionAssets()
|
||||||
|
{
|
||||||
|
var setup = new Setup();
|
||||||
|
setup.Parts.Add(0x01000001u);
|
||||||
|
setup.DefaultScale.Add(Vector3.One);
|
||||||
|
var loader = new Loader();
|
||||||
|
loader.Add(ReadyAnimation, AnimationWithFrames(4));
|
||||||
|
loader.Add(WalkAnimation, AnimationWithFrames(6));
|
||||||
|
var table = new MotionTable
|
||||||
|
{
|
||||||
|
DefaultStyle = (DRWMotionCommand)NonCombat,
|
||||||
|
};
|
||||||
|
table.StyleDefaults[(DRWMotionCommand)NonCombat] =
|
||||||
|
(DRWMotionCommand)Ready;
|
||||||
|
table.Cycles[(int)((NonCombat << 16) | (Ready & 0xFFFFFFu))] =
|
||||||
|
Motion(ReadyAnimation);
|
||||||
|
table.Cycles[(int)((NonCombat << 16) | (Walk & 0xFFFFFFu))] =
|
||||||
|
Motion(WalkAnimation);
|
||||||
|
return (setup, table, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Animation AnimationWithFrames(int count)
|
||||||
|
{
|
||||||
|
var animation = new Animation();
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var frame = new AnimationFrame(1);
|
||||||
|
frame.Frames.Add(new Frame
|
||||||
|
{
|
||||||
|
Origin = Vector3.Zero,
|
||||||
|
Orientation = Quaternion.Identity,
|
||||||
|
});
|
||||||
|
animation.PartFrames.Add(frame);
|
||||||
|
}
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MotionData Motion(uint animationId)
|
||||||
|
{
|
||||||
|
var data = new MotionData();
|
||||||
|
QualifiedDataId<Animation> qualified = animationId;
|
||||||
|
data.Anims.Add(new AnimData
|
||||||
|
{
|
||||||
|
AnimId = qualified,
|
||||||
|
LowFrame = 0,
|
||||||
|
HighFrame = -1,
|
||||||
|
Framerate = 30f,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Loader : IAnimationLoader
|
||||||
|
{
|
||||||
|
private readonly Dictionary<uint, Animation> _animations = [];
|
||||||
|
public void Add(uint id, Animation animation) => _animations[id] = animation;
|
||||||
|
public Animation? LoadAnimation(uint id) =>
|
||||||
|
_animations.GetValueOrDefault(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CountingResources : ILiveEntityResourceLifecycle
|
||||||
|
{
|
||||||
|
public int RegisterCount { get; private set; }
|
||||||
|
public int UnregisterCount { get; private set; }
|
||||||
|
public void Register(WorldEntity entity) => RegisterCount++;
|
||||||
|
public void Unregister(WorldEntity entity) => UnregisterCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ public sealed class RuntimeOptionsTests
|
||||||
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
|
// Default-on: RetailCloseDegrades is true unless explicitly disabled.
|
||||||
Assert.True(opts.RetailCloseDegrades);
|
Assert.True(opts.RetailCloseDegrades);
|
||||||
Assert.False(opts.DumpSceneryZ);
|
Assert.False(opts.DumpSceneryZ);
|
||||||
|
Assert.False(opts.DumpClothing);
|
||||||
Assert.Null(opts.LegacyStreamRadius);
|
Assert.Null(opts.LegacyStreamRadius);
|
||||||
Assert.False(opts.UiProbeDump);
|
Assert.False(opts.UiProbeDump);
|
||||||
Assert.Null(opts.UiProbeScript);
|
Assert.Null(opts.UiProbeScript);
|
||||||
|
|
@ -190,6 +191,7 @@ public sealed class RuntimeOptionsTests
|
||||||
["ACDREAM_NO_AUDIO"] = "1",
|
["ACDREAM_NO_AUDIO"] = "1",
|
||||||
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
||||||
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
||||||
|
["ACDREAM_DUMP_CLOTHING"] = "1",
|
||||||
}));
|
}));
|
||||||
Assert.True(allOn.DevTools);
|
Assert.True(allOn.DevTools);
|
||||||
Assert.True(allOn.UncappedRendering);
|
Assert.True(allOn.UncappedRendering);
|
||||||
|
|
@ -197,6 +199,7 @@ public sealed class RuntimeOptionsTests
|
||||||
Assert.True(allOn.NoAudio);
|
Assert.True(allOn.NoAudio);
|
||||||
Assert.True(allOn.EnableSkyPesDebug);
|
Assert.True(allOn.EnableSkyPesDebug);
|
||||||
Assert.True(allOn.DumpSceneryZ);
|
Assert.True(allOn.DumpSceneryZ);
|
||||||
|
Assert.True(allOn.DumpClothing);
|
||||||
|
|
||||||
// Any non-"1" value leaves them off, matching the
|
// Any non-"1" value leaves them off, matching the
|
||||||
// string.Equals(env, "1", StringComparison.Ordinal) check.
|
// string.Equals(env, "1", StringComparison.Ordinal) check.
|
||||||
|
|
@ -208,6 +211,7 @@ public sealed class RuntimeOptionsTests
|
||||||
["ACDREAM_NO_AUDIO"] = "2",
|
["ACDREAM_NO_AUDIO"] = "2",
|
||||||
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
||||||
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
||||||
|
["ACDREAM_DUMP_CLOTHING"] = "true",
|
||||||
}));
|
}));
|
||||||
Assert.False(anyOther.DevTools);
|
Assert.False(anyOther.DevTools);
|
||||||
Assert.False(anyOther.UncappedRendering);
|
Assert.False(anyOther.UncappedRendering);
|
||||||
|
|
@ -215,6 +219,7 @@ public sealed class RuntimeOptionsTests
|
||||||
Assert.False(anyOther.NoAudio);
|
Assert.False(anyOther.NoAudio);
|
||||||
Assert.False(anyOther.EnableSkyPesDebug);
|
Assert.False(anyOther.EnableSkyPesDebug);
|
||||||
Assert.False(anyOther.DumpSceneryZ);
|
Assert.False(anyOther.DumpSceneryZ);
|
||||||
|
Assert.False(anyOther.DumpClothing);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ public sealed class GameWindowLiveEntityCompositionTests
|
||||||
[InlineData("LeaveWorldLiveEntityRuntimeComponents")]
|
[InlineData("LeaveWorldLiveEntityRuntimeComponents")]
|
||||||
[InlineData("WithdrawLiveEntityWorldProjection")]
|
[InlineData("WithdrawLiveEntityWorldProjection")]
|
||||||
[InlineData("RebindAnimatedEntityForAppearance")]
|
[InlineData("RebindAnimatedEntityForAppearance")]
|
||||||
|
[InlineData("OnLiveEntitySpawned")]
|
||||||
|
[InlineData("OnLiveEntitySpawnedLocked")]
|
||||||
|
[InlineData("RehydrateServerEntitiesForLandblock")]
|
||||||
|
[InlineData("TryInitializeLiveCenter")]
|
||||||
|
[InlineData("CompleteLiveEntityReady")]
|
||||||
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
||||||
{
|
{
|
||||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
||||||
|
|
@ -36,6 +41,8 @@ public sealed class GameWindowLiveEntityCompositionTests
|
||||||
[InlineData(typeof(LiveEntityProjectionWithdrawalController))]
|
[InlineData(typeof(LiveEntityProjectionWithdrawalController))]
|
||||||
[InlineData(typeof(LocalPlayerShadowState))]
|
[InlineData(typeof(LocalPlayerShadowState))]
|
||||||
[InlineData(typeof(LiveEntityAppearanceBinding))]
|
[InlineData(typeof(LiveEntityAppearanceBinding))]
|
||||||
|
[InlineData(typeof(LiveEntityHydrationController))]
|
||||||
|
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
|
||||||
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
||||||
{
|
{
|
||||||
foreach (FieldInfo field in helperType.GetFields(
|
foreach (FieldInfo field in helperType.GetFields(
|
||||||
|
|
|
||||||
1413
tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
Normal file
1413
tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,117 @@
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.World;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.World;
|
||||||
|
|
||||||
|
public sealed class LiveEntityWorldOriginCoordinatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LocalPlayerPosition_InitializesOnceAndReturnsResidentRecoverySet()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
var origin = new LiveWorldOriginState();
|
||||||
|
origin.SetPlaceholder(10, 20);
|
||||||
|
var world = new GpuWorldState();
|
||||||
|
world.AddLandblock(EmptyLandblock(0x0101FFFFu));
|
||||||
|
world.AddLandblock(EmptyLandblock(0x0202FFFFu));
|
||||||
|
var coordinator = new LiveEntityWorldOriginCoordinator(
|
||||||
|
origin,
|
||||||
|
Streaming(world),
|
||||||
|
world,
|
||||||
|
Reveal(),
|
||||||
|
() => playerGuid,
|
||||||
|
_ => false);
|
||||||
|
|
||||||
|
LiveEntityOriginInitialization ignored = coordinator.TryInitialize(
|
||||||
|
Spawn(0x50000002u, 0x09090001u));
|
||||||
|
Assert.False(ignored.IsKnown);
|
||||||
|
|
||||||
|
LiveEntityOriginInitialization initialized = coordinator.TryInitialize(
|
||||||
|
Spawn(playerGuid, 0x03040001u));
|
||||||
|
|
||||||
|
Assert.True(initialized.IsKnown);
|
||||||
|
Assert.True(origin.IsKnown);
|
||||||
|
Assert.Equal(3, origin.CenterX);
|
||||||
|
Assert.Equal(4, origin.CenterY);
|
||||||
|
Assert.Equal(
|
||||||
|
[0x0101FFFFu, 0x0202FFFFu],
|
||||||
|
initialized.AlreadyLoadedLandblocks.Order().ToArray());
|
||||||
|
|
||||||
|
LiveEntityOriginInitialization repeated = coordinator.TryInitialize(
|
||||||
|
Spawn(playerGuid, 0x07080001u));
|
||||||
|
Assert.Empty(repeated.AlreadyLoadedLandblocks);
|
||||||
|
Assert.Equal(3, origin.CenterX);
|
||||||
|
Assert.Equal(4, origin.CenterY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reset_AllowsNextSessionPlayerPositionToOwnOrigin()
|
||||||
|
{
|
||||||
|
const uint playerGuid = 0x50000001u;
|
||||||
|
var origin = new LiveWorldOriginState();
|
||||||
|
var world = new GpuWorldState();
|
||||||
|
var coordinator = new LiveEntityWorldOriginCoordinator(
|
||||||
|
origin,
|
||||||
|
Streaming(world),
|
||||||
|
world,
|
||||||
|
Reveal(),
|
||||||
|
() => playerGuid,
|
||||||
|
_ => false);
|
||||||
|
|
||||||
|
Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x03040001u)).IsKnown);
|
||||||
|
origin.Reset();
|
||||||
|
Assert.False(coordinator.IsKnown);
|
||||||
|
|
||||||
|
Assert.True(coordinator.TryInitialize(Spawn(playerGuid, 0x07080001u)).IsKnown);
|
||||||
|
Assert.Equal(7, origin.CenterX);
|
||||||
|
Assert.Equal(8, origin.CenterY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StreamingController Streaming(GpuWorldState world) => new(
|
||||||
|
(_, _, _) => { },
|
||||||
|
(_, _) => { },
|
||||||
|
_ => Array.Empty<LandblockStreamResult>(),
|
||||||
|
(_, _) => { },
|
||||||
|
world,
|
||||||
|
nearRadius: 1,
|
||||||
|
farRadius: 2);
|
||||||
|
|
||||||
|
private static WorldRevealCoordinator Reveal() => new(
|
||||||
|
(_, _) => true,
|
||||||
|
_ => true,
|
||||||
|
(_, _) => true,
|
||||||
|
() => true,
|
||||||
|
(_, _) => { },
|
||||||
|
() => { },
|
||||||
|
_ => false);
|
||||||
|
|
||||||
|
private static LoadedLandblock EmptyLandblock(uint id) => new(
|
||||||
|
id,
|
||||||
|
new DatReaderWriter.DBObjs.LandBlock(),
|
||||||
|
Array.Empty<WorldEntity>());
|
||||||
|
|
||||||
|
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) => new(
|
||||||
|
Guid: guid,
|
||||||
|
Position: new CreateObject.ServerPosition(
|
||||||
|
cell,
|
||||||
|
12f,
|
||||||
|
24f,
|
||||||
|
6f,
|
||||||
|
1f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f),
|
||||||
|
SetupTableId: 0x02000001u,
|
||||||
|
AnimPartChanges: [],
|
||||||
|
TextureChanges: [],
|
||||||
|
SubPalettes: [],
|
||||||
|
BasePaletteId: null,
|
||||||
|
ObjScale: null,
|
||||||
|
Name: "origin fixture",
|
||||||
|
ItemType: null,
|
||||||
|
MotionState: null,
|
||||||
|
MotionTableId: null);
|
||||||
|
}
|
||||||
|
|
@ -258,4 +258,43 @@ public sealed class ObjectTableWiringTests
|
||||||
Assert.Equal("Refreshed Name", table.Get(guid)!.Name);
|
Assert.Equal("Refreshed Name", table.Get(guid)!.Name);
|
||||||
Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]);
|
Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerationReplacement_InvalidatedByRemovalObserver_DoesNotInstallOuterQualities()
|
||||||
|
{
|
||||||
|
const uint guid = 0x80000500u;
|
||||||
|
var table = new ClientObjectTable();
|
||||||
|
table.Ingest(MinimalWeenie(guid));
|
||||||
|
bool accepting = true;
|
||||||
|
table.ObjectRemovalClassified += removal =>
|
||||||
|
{
|
||||||
|
if (removal.Reason is not ClientObjectRemovalReason.GenerationReplacement)
|
||||||
|
return;
|
||||||
|
|
||||||
|
table.Ingest(MinimalWeenie(guid) with { Name = "Nested Newer" });
|
||||||
|
accepting = false;
|
||||||
|
};
|
||||||
|
var outer = new WorldSession.EntitySpawn(
|
||||||
|
Guid: guid,
|
||||||
|
Position: null,
|
||||||
|
SetupTableId: null,
|
||||||
|
AnimPartChanges: [],
|
||||||
|
TextureChanges: [],
|
||||||
|
SubPalettes: [],
|
||||||
|
BasePaletteId: null,
|
||||||
|
ObjScale: null,
|
||||||
|
Name: "Outer Older",
|
||||||
|
ItemType: null,
|
||||||
|
MotionState: null,
|
||||||
|
MotionTableId: null);
|
||||||
|
|
||||||
|
bool applied = ObjectTableWiring.ApplyEntitySpawn(
|
||||||
|
table,
|
||||||
|
outer,
|
||||||
|
replaceGeneration: true,
|
||||||
|
accepting: () => accepting);
|
||||||
|
|
||||||
|
Assert.False(applied);
|
||||||
|
Assert.Equal("Nested Newer", table.Get(guid)!.Name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue