refactor(world): extract live entity hydration

This commit is contained in:
Erik 2026-07-21 17:32:45 +02:00
parent 27dcd0ecb7
commit d10c5f2d54
17 changed files with 4310 additions and 1269 deletions

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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")),

View 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]);
}
}

View 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());
}
}

View file

@ -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

View file

@ -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>

View file

@ -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);
}

View file

@ -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++;
}
}

View file

@ -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]

View file

@ -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(

File diff suppressed because it is too large Load diff

View file

@ -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);
}

View file

@ -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);
}
}