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;
|
||||
|
||||
/// <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>
|
||||
public event Action<uint>? ProjectionPoseReady;
|
||||
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
|
||||
|
|
@ -448,14 +448,46 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
// CPhysicsObj::set_hidden (0x00514C60).
|
||||
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
|
||||
}
|
||||
ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion;
|
||||
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
|
||||
Console.WriteLine(
|
||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
$"location={parentLocation} placement={placement}");
|
||||
Relations.MarkProjected(pending, candidateKind);
|
||||
ProjectionPoseReady?.Invoke(childGuid);
|
||||
EntityReady?.Invoke(childGuid);
|
||||
return true;
|
||||
return PublishEntityReadyExact(
|
||||
_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(
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
/// <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(
|
||||
MotionTable motionTable,
|
||||
CreateObject.ServerMotionState? wireState)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public sealed record RuntimeOptions(
|
|||
bool RetailCloseDegrades,
|
||||
bool DumpSceneryZ,
|
||||
bool DumpLiveSpawns,
|
||||
bool DumpClothing,
|
||||
int? LegacyStreamRadius,
|
||||
bool RetailUi,
|
||||
string? AcDir,
|
||||
|
|
@ -89,6 +90,7 @@ public sealed record RuntimeOptions(
|
|||
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
||||
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
||||
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
||||
DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")),
|
||||
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
||||
// top of the quality preset's radii. Null when unset or invalid.
|
||||
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 VelocityAuthorityVersion { 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; }
|
||||
/// <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; }
|
||||
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
||||
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
||||
|
|
@ -320,6 +345,7 @@ public sealed class LiveEntityRecord
|
|||
VectorAuthorityVersion++;
|
||||
VelocityAuthorityVersion++;
|
||||
MovementAuthorityVersion++;
|
||||
CreateIntegrationVersion++;
|
||||
}
|
||||
|
||||
internal void SetChildNoDraw(bool noDraw)
|
||||
|
|
@ -2031,6 +2057,111 @@ public sealed class LiveEntityRuntime
|
|||
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()
|
||||
{
|
||||
if (_isClearing || _isRegisteringResources)
|
||||
|
|
@ -2586,6 +2717,11 @@ public sealed class LiveEntityRuntime
|
|||
record.IsSpatiallyProjected = false;
|
||||
record.IsSpatiallyVisible = false;
|
||||
record.WorldSpawnPublished = false;
|
||||
record.InitialHydrationCompleted = false;
|
||||
record.CreateProjectionSynchronizationPending = false;
|
||||
record.ProjectionHydrationInProgress = false;
|
||||
record.ProjectionHydrationCreateVersion = 0UL;
|
||||
record.ProjectionHydrationRetryRequested = false;
|
||||
record.WorldEntity = null;
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -101,16 +101,31 @@ public static class ObjectTableWiring
|
|||
/// owning runtime accepts its physics generation. Internal for a
|
||||
/// socket-free conformance test of the subscription body.
|
||||
/// </summary>
|
||||
public static void ApplyEntitySpawn(
|
||||
public static bool ApplyEntitySpawn(
|
||||
ClientObjectTable table,
|
||||
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);
|
||||
if (replaceGeneration)
|
||||
table.ReplaceGeneration(data, spawn.InstanceSequence);
|
||||
{
|
||||
return table.ReplaceGeneration(
|
||||
data,
|
||||
spawn.InstanceSequence,
|
||||
accepting) is not null;
|
||||
}
|
||||
else
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -576,7 +576,10 @@ public sealed class ClientObjectTable
|
|||
/// Atomically replaces the retained qualities for a newer server object
|
||||
/// incarnation while publishing a generation-specific teardown event.
|
||||
/// </summary>
|
||||
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
|
||||
public ClientObject? ReplaceGeneration(
|
||||
WeenieData data,
|
||||
ushort generation,
|
||||
Func<bool>? canInstallReplacement = null)
|
||||
{
|
||||
RemoveCore(
|
||||
data.Guid,
|
||||
|
|
@ -584,6 +587,13 @@ public sealed class ClientObjectTable
|
|||
generation,
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Assert.True(opts.RetailCloseDegrades);
|
||||
Assert.False(opts.DumpSceneryZ);
|
||||
Assert.False(opts.DumpClothing);
|
||||
Assert.Null(opts.LegacyStreamRadius);
|
||||
Assert.False(opts.UiProbeDump);
|
||||
Assert.Null(opts.UiProbeScript);
|
||||
|
|
@ -190,6 +191,7 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_NO_AUDIO"] = "1",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "1",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = "1",
|
||||
["ACDREAM_DUMP_CLOTHING"] = "1",
|
||||
}));
|
||||
Assert.True(allOn.DevTools);
|
||||
Assert.True(allOn.UncappedRendering);
|
||||
|
|
@ -197,6 +199,7 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.True(allOn.NoAudio);
|
||||
Assert.True(allOn.EnableSkyPesDebug);
|
||||
Assert.True(allOn.DumpSceneryZ);
|
||||
Assert.True(allOn.DumpClothing);
|
||||
|
||||
// Any non-"1" value leaves them off, matching the
|
||||
// string.Equals(env, "1", StringComparison.Ordinal) check.
|
||||
|
|
@ -208,6 +211,7 @@ public sealed class RuntimeOptionsTests
|
|||
["ACDREAM_NO_AUDIO"] = "2",
|
||||
["ACDREAM_ENABLE_SKY_PES"] = "on",
|
||||
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
|
||||
["ACDREAM_DUMP_CLOTHING"] = "true",
|
||||
}));
|
||||
Assert.False(anyOther.DevTools);
|
||||
Assert.False(anyOther.UncappedRendering);
|
||||
|
|
@ -215,6 +219,7 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(anyOther.NoAudio);
|
||||
Assert.False(anyOther.EnableSkyPesDebug);
|
||||
Assert.False(anyOther.DumpSceneryZ);
|
||||
Assert.False(anyOther.DumpClothing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
[InlineData("LeaveWorldLiveEntityRuntimeComponents")]
|
||||
[InlineData("WithdrawLiveEntityWorldProjection")]
|
||||
[InlineData("RebindAnimatedEntityForAppearance")]
|
||||
[InlineData("OnLiveEntitySpawned")]
|
||||
[InlineData("OnLiveEntitySpawnedLocked")]
|
||||
[InlineData("RehydrateServerEntitiesForLandblock")]
|
||||
[InlineData("TryInitializeLiveCenter")]
|
||||
[InlineData("CompleteLiveEntityReady")]
|
||||
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
|
||||
{
|
||||
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
|
||||
|
|
@ -36,6 +41,8 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
[InlineData(typeof(LiveEntityProjectionWithdrawalController))]
|
||||
[InlineData(typeof(LocalPlayerShadowState))]
|
||||
[InlineData(typeof(LiveEntityAppearanceBinding))]
|
||||
[InlineData(typeof(LiveEntityHydrationController))]
|
||||
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
|
||||
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
|
||||
{
|
||||
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(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