diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
index e0eedb10..3ad0d64f 100644
--- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
+++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md
@@ -204,6 +204,13 @@ on ParentEvent(parent, child):
strictly advance the child's Position timestamp
apply parent + placement without creating a second position counter
+on CreateObject with Parent but no AnimationFrame field:
+ PhysicsDesc construction initializes animframe_id to 0
+ PhysicsDesc::UnPack explicitly leaves animframe_id at 0 when absent
+ set_description installs placement 0 before the parent relation is realized
+ therefore treat absent wire AnimationFrame as placement 0, not as a
+ missing relation
+
on PickupEvent(object):
require the exact live Instance
strictly advance the shared Position timestamp
diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
index 4da28c83..bf270828 100644
--- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs
+++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
@@ -98,9 +98,12 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock)
{
if (spawn.ParentGuid is { } parentGuid and not 0
- && spawn.ParentLocation is { } parentLocation
- && spawn.PlacementId is { } placementId)
+ && spawn.ParentLocation is { } parentLocation)
{
+ // PhysicsDesc::PhysicsDesc / UnPack (0x0051D4D0 /
+ // 0x0051DDD0) default an absent AnimationFrame to zero.
+ // Parent remains a complete relation in that case.
+ uint placementId = spawn.PlacementId ?? 0u;
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid,
spawn.Guid,
@@ -118,6 +121,51 @@ public sealed class EquippedChildRenderController : IDisposable
}
}
+ ///
+ /// Retail SmartBox::UpdateVisualDesc @ 0x00451F40 replaces an
+ /// attached child's visual description without requiring a world
+ /// Position. Re-realize the last accepted relation from the newest
+ /// canonical snapshot while retaining the logical entity.
+ ///
+ internal bool TryApplyAttachedAppearance(
+ LiveEntityRecord expectedRecord,
+ ulong expectedObjDescAuthorityVersion)
+ {
+ ArgumentNullException.ThrowIfNull(expectedRecord);
+ lock (_datLock)
+ {
+ if (!_liveEntities.IsCurrentObjDescAuthority(
+ expectedRecord,
+ expectedObjDescAuthorityVersion)
+ || expectedRecord.ProjectionKind is not
+ LiveEntityProjectionKind.Attached
+ || !expectedRecord.IsSpatiallyProjected
+ || expectedRecord.WorldEntity is not { } expectedEntity
+ || !Relations.RestoreLastAccepted(expectedRecord.ServerGuid))
+ {
+ return false;
+ }
+
+ bool projected = ResolveAndTryRealize(expectedRecord.ServerGuid);
+ if (projected)
+ {
+ // WithdrawPriorProjection removes the complete attached
+ // subtree and retains each descendant relation for recovery.
+ // The ObjDesc transaction is not published until that same
+ // subtree is synchronously whole again.
+ RetryWaitingDescendants(expectedRecord.ServerGuid);
+ }
+ return projected
+ && _liveEntities.IsCurrentObjDescAuthority(
+ expectedRecord,
+ expectedObjDescAuthorityVersion)
+ && expectedRecord.ProjectionKind is
+ LiveEntityProjectionKind.Attached
+ && expectedRecord.IsSpatiallyProjected
+ && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
+ }
+ }
+
///
/// Completes attachment projection after a root world entity has been
/// registered. Relation acceptance intentionally happens before root
@@ -448,46 +496,31 @@ 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);
+ LiveEntityReadyCandidate readyCandidate =
+ LiveEntityReadyCandidate.Capture(childRecord);
ProjectionPoseReady?.Invoke(childGuid);
return PublishEntityReadyExact(
_liveEntities,
- childRecord,
- readyCreateIntegrationVersion,
- entity,
+ readyCandidate,
EntityReady);
}
internal static bool PublishEntityReadyExact(
LiveEntityRuntime runtime,
- LiveEntityRecord expectedRecord,
- ulong expectedCreateIntegrationVersion,
- WorldEntity expectedEntity,
+ LiveEntityReadyCandidate candidate,
Action? publish)
{
ArgumentNullException.ThrowIfNull(runtime);
- ArgumentNullException.ThrowIfNull(expectedRecord);
- ArgumentNullException.ThrowIfNull(expectedEntity);
- if (!runtime.IsCurrentCreateIntegration(
- expectedRecord,
- expectedCreateIntegrationVersion)
- || !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity))
- {
+ if (!candidate.IsCurrent(runtime))
return false;
- }
- publish?.Invoke(new LiveEntityReadyCandidate(
- expectedRecord,
- expectedCreateIntegrationVersion));
- return runtime.IsCurrentCreateIntegration(
- expectedRecord,
- expectedCreateIntegrationVersion)
- && ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
+ publish?.Invoke(candidate);
+ return candidate.IsCurrent(runtime);
}
private void PublishChildPose(
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e4d299cb..7bc131b8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -749,8 +749,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// guid. Captured before the renderability gate so no-position inventory /
/// parented children retain Setup and parent metadata; hydration rereads
/// this canonical snapshot after every relationship callback.
- /// reuses the cached position/setup/motion
- /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
+ /// reuses the cached
+ /// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
+ /// updated visuals.
///
private IReadOnlyDictionary LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
@@ -1750,6 +1751,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
+ AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
+
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi)
{
@@ -2270,13 +2273,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_lightingSink!,
setupId => _dats!.Get(setupId));
+ parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
_effectPoses,
- TryAcceptParentForRender,
+ parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord,
@@ -2567,8 +2571,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Objects,
_datLock,
projectionMaterializer,
- new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink(
- _equippedChildRenderer.OnSpawn),
+ new AcDream.App.World.LiveEntityRelationshipProjection(
+ _equippedChildRenderer),
new AcDream.App.World.LiveEntityReadyPublisher(
_liveEntities,
_entityEffects!,
@@ -2578,11 +2582,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
PublishLocalPhysicsTimestamps,
() => _playerServerGuid,
_options.DumpLiveSpawns ? Console.WriteLine : null);
+ parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(
new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
RouteSameGenerationCreateObject));
_equippedChildRenderer.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate);
+ _liveEntityHydration.AppearanceApplied += guid =>
+ {
+ if (guid == _playerServerGuid)
+ _paperdollDollDirty = true;
+ };
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@@ -2823,7 +2833,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Deleted: deletion => _inboundEntityEvents.Run(
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
- this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
+ _liveEntityHydration!, pickup,
+ static (hydration, value) => hydration.OnPickup(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
@@ -2833,11 +2844,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
StateUpdated: state => _inboundEntityEvents.Run(
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
- this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
+ _liveEntityHydration!, parent,
+ static (hydration, value) => hydration.OnParent(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
- this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
+ _liveEntityHydration!, appearance,
+ static (hydration, value) => hydration.OnAppearance(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
@@ -3037,46 +3050,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
candidate.Generation));
}
- ///
- /// Server broadcast a 0xF625 ObjDescEvent — a creature/player's
- /// appearance changed (equip / unequip / tailoring / recipe result /
- /// character option toggle). The wire payload only carries the new
- /// ModelData (palette + texture + animpart changes), not position or
- /// motion, so we splice it onto the cached spawn and rehydrate only the
- /// visual projection. Retail SmartBox::UpdateVisualDesc calls
- /// CPhysicsObj::DoObjDescChangesFromDefault on the existing object;
- /// accordingly, entity identity and all animation/movement/physics state
- /// survive while MeshRefs, palette, and part overrides are replaced.
- ///
- private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
- {
- if (!_liveEntities!.TryApplyObjDesc(update, out var newSpawn))
- {
- // Server can broadcast ObjDescEvent before we've seen a
- // CreateObject for this guid (race on landblock entry, or
- // if the entity is in a state we couldn't render). Drop —
- // when CreateObject lands, ACE includes the same ModelData
- // body inside it, so the appearance won't be lost.
- return;
- }
-
- if (_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
- {
- LiveEntityAppearanceUpdateState? appearanceState =
- LiveEntityAppearanceBinding.Capture(_liveEntities, update.Guid);
- _liveEntityHydration!.ApplyAppearance(
- record,
- newSpawn,
- appearanceState);
- }
-
- // Slice 2: flag the paperdoll doll to re-clone the player's newly
- // mutated appearance on the next doll pass (the C# analog of
- // RedressCreature). The rebuild is deferred until the doll is visible.
- if (update.Guid == _playerServerGuid)
- _paperdollDollDirty = true;
- }
-
///
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
@@ -3198,20 +3171,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
}
- OnLiveAppearanceUpdated(refresh.Appearance);
+ _liveEntityHydration!.OnAppearance(refresh.Appearance);
- if (refresh.Parent is { } parent
- && _liveEntities!.TryApplyCreateParent(parent, out _))
- {
- _equippedChildRenderer?.OnCreateParentAccepted(parent);
- }
+ if (refresh.Parent is { } parent)
+ _liveEntityHydration.OnCreateParentAccepted(parent);
else if (refresh.Position is { } position)
{
OnLivePositionUpdated(position);
}
else if (refresh.Pickup is { } pickup)
{
- OnLiveEntityPickedUp(pickup);
+ _liveEntityHydration.OnPickup(pickup);
}
if (refresh.Movement is { } movement)
@@ -3220,49 +3190,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
OnLiveVectorUpdated(refresh.Vector);
}
- ///
- /// Retail PickupEvent is a POSITION_TS update, not object destruction.
- /// It leaves the world projection while retaining the weenie, generation,
- /// and every other timestamp for later inventory/parent events.
- ///
- private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
- {
- if (!_liveEntities!.TryApplyPickup(pickup, out _))
- return;
-
- AcDream.App.Rendering.ChildUnparentDisposition childDisposition =
- _equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid)
- ?? AcDream.App.Rendering.ChildUnparentDisposition.NotAttached;
- bool removed = childDisposition switch
- {
- AcDream.App.Rendering.ChildUnparentDisposition.Completed => true,
- AcDream.App.Rendering.ChildUnparentDisposition.NotAttached => true,
- _ => false,
- };
-
- if (removed
- && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
- {
- Console.WriteLine(
- $"live: pickup guid=0x{pickup.Guid:X8} instSeq={pickup.InstanceSequence} posSeq={pickup.PositionSequence}");
- }
- }
-
- ///
- /// ParentEvent may precede either CreateObject. The focused child renderer
- /// retains it; its realization callback performs the canonical timestamp
- /// acceptance immediately before changing the projection.
- ///
- private void OnLiveParentUpdated(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
- {
- _equippedChildRenderer?.OnParentEvent(update);
- }
-
- private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
- {
- return _liveEntities!.TryApplyParent(update, out _);
- }
-
private void PublishLocalPhysicsTimestamps(
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs
index d361a7fb..a1d06811 100644
--- a/src/AcDream.App/World/LiveEntityHydrationController.cs
+++ b/src/AcDream.App/World/LiveEntityHydrationController.cs
@@ -1,6 +1,8 @@
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.World;
namespace AcDream.App.World;
@@ -29,19 +31,55 @@ internal interface ILiveEntityProjectionMaterializer
void ResetSessionState();
}
-internal interface ILiveEntitySpawnRelationshipSink
+internal interface ILiveEntityRelationshipProjection
{
void OnSpawn(WorldSession.EntitySpawn spawn);
+ void OnParent(ParentEvent.Parsed update);
+ void OnCreateParentAccepted(CreateParentUpdate update);
+ ChildUnparentDisposition OnChildBecameUnparented(uint childGuid);
+ bool TryApplyAttachedAppearance(
+ LiveEntityRecord record,
+ ulong objDescAuthorityVersion);
}
internal interface ILiveEntityReadyPublisher
{
- bool Publish(LiveEntityRecord expectedRecord);
+ bool Publish(LiveEntityReadyCandidate candidate);
}
internal readonly record struct LiveEntityReadyCandidate(
LiveEntityRecord Record,
- ulong CreateIntegrationVersion);
+ ulong CreateIntegrationVersion,
+ ulong PositionAuthorityVersion,
+ ulong ProjectionMutationVersion,
+ ulong ObjDescAuthorityVersion,
+ LiveEntityProjectionKind ProjectionKind,
+ bool IsSpatiallyProjected,
+ WorldEntity? WorldEntity)
+{
+ internal static LiveEntityReadyCandidate Capture(LiveEntityRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ return new(
+ record,
+ record.CreateIntegrationVersion,
+ record.PositionAuthorityVersion,
+ record.ProjectionMutationVersion,
+ record.ObjDescAuthorityVersion,
+ record.ProjectionKind,
+ record.IsSpatiallyProjected,
+ record.WorldEntity);
+ }
+
+ internal bool IsCurrent(LiveEntityRuntime runtime) =>
+ runtime.IsCurrentCreateIntegration(Record, CreateIntegrationVersion)
+ && Record.PositionAuthorityVersion == PositionAuthorityVersion
+ && Record.ProjectionMutationVersion == ProjectionMutationVersion
+ && Record.ObjDescAuthorityVersion == ObjDescAuthorityVersion
+ && Record.ProjectionKind == ProjectionKind
+ && Record.IsSpatiallyProjected == IsSpatiallyProjected
+ && ReferenceEquals(Record.WorldEntity, WorldEntity);
+}
internal interface ILiveEntityNetworkUpdateSink
{
@@ -104,7 +142,7 @@ internal sealed class LiveEntityHydrationController
private readonly ClientObjectTable _objects;
private readonly object _datLock;
private readonly ILiveEntityProjectionMaterializer _materializer;
- private readonly ILiveEntitySpawnRelationshipSink _relationships;
+ private readonly ILiveEntityRelationshipProjection _relationships;
private readonly ILiveEntityReadyPublisher _ready;
private readonly ILiveEntityWorldOriginCoordinator _origin;
private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
@@ -117,7 +155,7 @@ internal sealed class LiveEntityHydrationController
ClientObjectTable objects,
object datLock,
ILiveEntityProjectionMaterializer materializer,
- ILiveEntitySpawnRelationshipSink relationships,
+ ILiveEntityRelationshipProjection relationships,
ILiveEntityReadyPublisher ready,
ILiveEntityWorldOriginCoordinator origin,
ILiveEntityNetworkUpdateSink networkUpdates,
@@ -139,6 +177,8 @@ internal sealed class LiveEntityHydrationController
_diagnostic = diagnostic;
}
+ internal event Action? AppearanceApplied;
+
public void OnCreate(WorldSession.EntitySpawn spawn)
{
// DatCollection uses one mutable reader cursor shared with streaming.
@@ -228,6 +268,14 @@ internal sealed class LiveEntityHydrationController
expectedCreateIntegrationVersion:
createIntegrationVersion);
}
+
+ if (_runtime.IsCurrentRecord(record)
+ && record.AppearanceProjectionSynchronizationPending)
+ {
+ SynchronizeAppearance(
+ record,
+ record.ObjDescAuthorityVersion);
+ }
}
}
catch (Exception applyFailure)
@@ -248,6 +296,68 @@ internal sealed class LiveEntityHydrationController
}
}
+ ///
+ /// Applies retail SmartBox::UpdateVisualDesc @ 0x00451F40 to the
+ /// retained object. Identity, animation owner, physics, effects, and cell
+ /// membership survive; only the DAT-backed visual projection and its
+ /// derived collision payload are replaced.
+ ///
+ public bool OnAppearance(ObjDescEvent.Parsed update)
+ {
+ lock (_datLock)
+ {
+ if (!_runtime.TryApplyObjDesc(update, out _)
+ || !_runtime.TryGetRecord(update.Guid, out LiveEntityRecord record))
+ {
+ return false;
+ }
+
+ return SynchronizeAppearance(
+ record,
+ record.ObjDescAuthorityVersion);
+ }
+ }
+
+ ///
+ /// Retail DoPickupEvent @ 0x00452240: consume the shared Position
+ /// timestamp, unparent, and leave world without destroying the logical
+ /// object or any incarnation-scoped owner.
+ ///
+ public bool OnPickup(PickupEvent.Parsed update)
+ {
+ if (!_runtime.TryApplyPickup(update, out _))
+ return false;
+
+ ChildUnparentDisposition disposition =
+ _relationships.OnChildBecameUnparented(update.Guid);
+ bool accepted = disposition is not ChildUnparentDisposition.Superseded;
+ if (accepted)
+ {
+ _diagnostic?.Invoke(
+ $"live: pickup guid=0x{update.Guid:X8} instSeq={update.InstanceSequence} posSeq={update.PositionSequence}");
+ }
+ return accepted;
+ }
+
+ ///
+ /// Parent events may precede either CreateObject. The relationship owner
+ /// retains wire order and invokes
+ /// only when both exact incarnations are addressable.
+ ///
+ public void OnParent(ParentEvent.Parsed update) =>
+ _relationships.OnParent(update);
+
+ internal bool TryAcceptParentForProjection(ParentEvent.Parsed update) =>
+ _runtime.TryApplyParent(update, out _);
+
+ internal bool OnCreateParentAccepted(CreateParentUpdate update)
+ {
+ if (!_runtime.TryApplyCreateParent(update, out _))
+ return false;
+ _relationships.OnCreateParentAccepted(update);
+ return true;
+ }
+
///
/// Reprojects retained live objects after their landblock is loaded. An
/// fully hydrated record only rebuckets. A retained partial projection
@@ -263,7 +373,8 @@ internal sealed class LiveEntityHydrationController
LiveEntityRecord[] records = _runtime.Records
.Where(record => (record.ServerGuid != _playerGuid()
|| !record.InitialHydrationCompleted
- || record.CreateProjectionSynchronizationPending)
+ || record.CreateProjectionSynchronizationPending
+ || record.AppearanceProjectionSynchronizationPending)
&& record.ProjectionCellId != 0
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
&& record.Snapshot.SetupTableId is not null)
@@ -279,6 +390,9 @@ internal sealed class LiveEntityHydrationController
if (!_runtime.IsCurrentRecord(record))
continue;
+ ulong projectedObjDescVersion = record.ObjDescAuthorityVersion;
+ bool projectedCanonicalAppearance = false;
+
if (record.CreateProjectionSynchronizationPending)
{
if (ProjectExact(
@@ -287,6 +401,7 @@ internal sealed class LiveEntityHydrationController
LiveProjectionPurpose.CreateSupersessionRecovery))
{
projected++;
+ projectedCanonicalAppearance = true;
}
}
else if (record.WorldEntity is not null
@@ -303,6 +418,24 @@ internal sealed class LiveEntityHydrationController
record,
record.Snapshot,
LiveProjectionPurpose.SpatialRecovery))
+ {
+ projected++;
+ projectedCanonicalAppearance = true;
+ }
+
+ if (projectedCanonicalAppearance
+ && record.AppearanceProjectionSynchronizationPending)
+ {
+ CompleteAppearanceProjectionSynchronization(
+ record,
+ projectedObjDescVersion);
+ }
+
+ if (_runtime.IsCurrentRecord(record)
+ && record.AppearanceProjectionSynchronizationPending
+ && SynchronizeAppearance(
+ record,
+ record.ObjDescAuthorityVersion))
{
projected++;
}
@@ -353,7 +486,8 @@ internal sealed class LiveEntityHydrationController
return false;
}
- return ProjectExact(
+ ulong projectedObjDescVersion = expectedRecord.ObjDescAuthorityVersion;
+ bool projected = ProjectExact(
expectedRecord,
acceptedSpawn,
expectedRecord.CreateProjectionSynchronizationPending
@@ -362,49 +496,128 @@ internal sealed class LiveEntityHydrationController
&& _runtime.IsCurrentPositionAuthority(
expectedRecord,
positionAuthorityVersion);
- }
- }
+ if (!projected)
+ return false;
- public bool ApplyAppearance(
- LiveEntityRecord expectedRecord,
- WorldSession.EntitySpawn acceptedSpawn,
- LiveEntityAppearanceUpdateState? appearanceUpdate)
- {
- ArgumentNullException.ThrowIfNull(expectedRecord);
- lock (_datLock)
- {
- return ProjectExact(
+ if (expectedRecord.AppearanceProjectionSynchronizationPending)
+ {
+ if (_runtime.IsCurrentObjDescAuthority(
+ expectedRecord,
+ projectedObjDescVersion))
+ {
+ CompleteAppearanceProjectionSynchronization(
+ expectedRecord,
+ projectedObjDescVersion);
+ }
+ else if (!SynchronizeAppearance(
+ expectedRecord,
+ expectedRecord.ObjDescAuthorityVersion))
+ {
+ return false;
+ }
+ }
+
+ return _runtime.IsCurrentPositionAuthority(
expectedRecord,
- acceptedSpawn,
- appearanceUpdate is null
- ? LiveProjectionPurpose.SpatialRecovery
- : LiveProjectionPurpose.AppearanceMutation,
- appearanceUpdate);
+ positionAuthorityVersion);
}
}
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
- if (!PublishReady(
- candidate.Record,
- candidate.CreateIntegrationVersion))
+ if (!PublishReady(candidate))
{
return false;
}
- return !candidate.Record.CreateProjectionSynchronizationPending
+ if (candidate.Record.AppearanceProjectionSynchronizationPending
+ && !candidate.Record.AppearanceHydrationInProgress)
+ {
+ CompleteAppearanceProjectionSynchronization(
+ candidate.Record,
+ candidate.ObjDescAuthorityVersion);
+ }
+
+ return candidate.IsCurrent(_runtime)
+ && (!candidate.Record.CreateProjectionSynchronizationPending
|| _runtime.TryCompleteCreateProjectionSynchronization(
candidate.Record,
- candidate.CreateIntegrationVersion);
+ candidate.CreateIntegrationVersion));
}
public void ResetSessionState() => _materializer.ResetSessionState();
+ private bool SynchronizeAppearance(
+ LiveEntityRecord expectedRecord,
+ ulong expectedObjDescAuthorityVersion)
+ {
+ while (true)
+ {
+ if (!_runtime.TryBeginAppearanceHydration(
+ expectedRecord,
+ expectedObjDescAuthorityVersion))
+ {
+ return false;
+ }
+
+ bool retry;
+ bool published;
+ try
+ {
+ if (expectedRecord.ProjectionKind is
+ LiveEntityProjectionKind.Attached)
+ {
+ published = _relationships.TryApplyAttachedAppearance(
+ expectedRecord,
+ expectedObjDescAuthorityVersion);
+ }
+ else
+ {
+ LiveEntityAppearanceUpdateState? appearanceState =
+ LiveEntityAppearanceBinding.Capture(
+ _runtime,
+ expectedRecord.ServerGuid);
+ if (appearanceState is null)
+ {
+ published = ProjectExact(
+ expectedRecord,
+ expectedRecord.Snapshot,
+ LiveProjectionPurpose.SpatialRecovery);
+ }
+ else
+ {
+ published = _materializer.TryMaterialize(
+ expectedRecord,
+ expectedRecord.Snapshot,
+ LiveProjectionPurpose.AppearanceMutation,
+ expectedRecord.CreateIntegrationVersion,
+ appearanceState);
+ }
+ }
+ }
+ finally
+ {
+ retry = _runtime.EndAppearanceHydration(expectedRecord);
+ }
+
+ if (retry && _runtime.IsCurrentRecord(expectedRecord))
+ {
+ expectedObjDescAuthorityVersion =
+ expectedRecord.ObjDescAuthorityVersion;
+ continue;
+ }
+
+ return published
+ && CompleteAppearanceProjectionSynchronization(
+ expectedRecord,
+ expectedObjDescAuthorityVersion);
+ }
+ }
+
private bool ProjectExact(
LiveEntityRecord expectedRecord,
WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose,
- LiveEntityAppearanceUpdateState? appearanceUpdate = null,
ulong? expectedCreateIntegrationVersion = null)
{
while (true)
@@ -433,7 +646,6 @@ internal sealed class LiveEntityHydrationController
expectedRecord,
acceptedSpawn,
purpose,
- appearanceUpdate,
createIntegrationVersion);
}
finally
@@ -449,7 +661,6 @@ internal sealed class LiveEntityHydrationController
// newest canonical snapshot; logical resources remain registered.
acceptedSpawn = expectedRecord.Snapshot;
purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
- appearanceUpdate = null;
expectedCreateIntegrationVersion =
expectedRecord.CreateIntegrationVersion;
}
@@ -459,7 +670,6 @@ internal sealed class LiveEntityHydrationController
LiveEntityRecord expectedRecord,
WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose,
- LiveEntityAppearanceUpdateState? appearanceUpdate,
ulong createIntegrationVersion)
{
_relationships.OnSpawn(acceptedSpawn);
@@ -550,8 +760,7 @@ internal sealed class LiveEntityHydrationController
expectedRecord,
expectedRecord.Snapshot,
purpose,
- createIntegrationVersion,
- appearanceUpdate);
+ createIntegrationVersion);
if (!materialized
|| !_runtime.IsCurrentCreateIntegration(
expectedRecord,
@@ -579,22 +788,46 @@ internal sealed class LiveEntityHydrationController
ulong expectedCreateIntegrationVersion)
{
if (!_runtime.IsCurrentCreateIntegration(
- expectedRecord,
- expectedCreateIntegrationVersion)
- || !_ready.Publish(expectedRecord)
- || !_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion))
{
return false;
}
+ return PublishReady(LiveEntityReadyCandidate.Capture(expectedRecord));
+ }
+
+ private bool PublishReady(LiveEntityReadyCandidate candidate)
+ {
+ LiveEntityRecord expectedRecord = candidate.Record;
+ if (!candidate.IsCurrent(_runtime)
+ || !_ready.Publish(candidate)
+ || !candidate.IsCurrent(_runtime))
+ {
+ return false;
+ }
+
return _runtime.TryMarkInitialHydrationCompleted(
expectedRecord,
- expectedCreateIntegrationVersion)
- && _runtime.IsCurrentCreateIntegration(
+ candidate.CreateIntegrationVersion)
+ && candidate.IsCurrent(_runtime);
+ }
+
+ private bool CompleteAppearanceProjectionSynchronization(
+ LiveEntityRecord expectedRecord,
+ ulong expectedObjDescAuthorityVersion)
+ {
+ if (!_runtime.TryCompleteAppearanceProjectionSynchronization(
expectedRecord,
- expectedCreateIntegrationVersion);
+ expectedObjDescAuthorityVersion))
+ {
+ return false;
+ }
+
+ AppearanceApplied?.Invoke(expectedRecord.ServerGuid);
+ return _runtime.IsCurrentObjDescAuthority(
+ expectedRecord,
+ expectedObjDescAuthorityVersion);
}
private void InitializeOriginAndRecoverLoaded(
diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs
index be5f9007..b6584cbc 100644
--- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs
+++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs
@@ -1,16 +1,48 @@
+using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
namespace AcDream.App.World;
-internal sealed class DelegateLiveEntitySpawnRelationshipSink(
- Action onSpawn) : ILiveEntitySpawnRelationshipSink
+internal sealed class LiveEntityRelationshipProjection(
+ EquippedChildRenderController children) : ILiveEntityRelationshipProjection
{
- private readonly Action _onSpawn =
- onSpawn ?? throw new ArgumentNullException(nameof(onSpawn));
+ private readonly EquippedChildRenderController _children =
+ children ?? throw new ArgumentNullException(nameof(children));
- public void OnSpawn(WorldSession.EntitySpawn spawn) => _onSpawn(spawn);
+ public void OnSpawn(WorldSession.EntitySpawn spawn) => _children.OnSpawn(spawn);
+ public void OnParent(ParentEvent.Parsed update) => _children.OnParentEvent(update);
+ public void OnCreateParentAccepted(CreateParentUpdate update) =>
+ _children.OnCreateParentAccepted(update);
+ public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid) =>
+ _children.OnChildBecameUnparented(childGuid);
+ public bool TryApplyAttachedAppearance(
+ LiveEntityRecord record,
+ ulong objDescAuthorityVersion) =>
+ _children.TryApplyAttachedAppearance(record, objDescAuthorityVersion);
+}
+
+///
+/// Fail-fast construction seam for the retail ParentEvent timestamp gate.
+/// The equipped-child renderer can be built before hydration, but no live
+/// session may resolve a relation until hydration binds this once.
+///
+internal sealed class DeferredLiveEntityParentAcceptance
+{
+ private Func? _accept;
+
+ public void Bind(Func accept)
+ {
+ ArgumentNullException.ThrowIfNull(accept);
+ if (Interlocked.CompareExchange(ref _accept, accept, null) is not null)
+ throw new InvalidOperationException("Live parent acceptance is already bound.");
+ }
+
+ public bool TryAccept(ParentEvent.Parsed update) =>
+ (_accept ?? throw new InvalidOperationException(
+ "Live parent acceptance must be bound before a session starts."))(update);
}
internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
@@ -49,31 +81,23 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
?? throw new ArgumentNullException(nameof(replayEffects));
}
- public bool Publish(LiveEntityRecord expectedRecord)
+ public bool Publish(LiveEntityReadyCandidate candidate)
{
+ LiveEntityRecord expectedRecord = candidate.Record;
ArgumentNullException.ThrowIfNull(expectedRecord);
- ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion;
- if (!_runtime.IsCurrentCreateIntegration(
- expectedRecord,
- createIntegrationVersion)
+ if (!candidate.IsCurrent(_runtime)
|| !_prepareEffects(expectedRecord)
- || !_runtime.IsCurrentCreateIntegration(
- expectedRecord,
- createIntegrationVersion))
+ || !candidate.IsCurrent(_runtime))
return false;
if (!_presentState(expectedRecord)
- || !_runtime.IsCurrentCreateIntegration(
- expectedRecord,
- createIntegrationVersion))
+ || !candidate.IsCurrent(_runtime))
{
return false;
}
return _replayEffects(expectedRecord)
- && _runtime.IsCurrentCreateIntegration(
- expectedRecord,
- createIntegrationVersion);
+ && candidate.IsCurrent(_runtime);
}
}
diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs
index 0e8f33a2..2b0386ff 100644
--- a/src/AcDream.App/World/LiveEntityRuntime.cs
+++ b/src/AcDream.App/World/LiveEntityRuntime.cs
@@ -201,6 +201,7 @@ public sealed class LiveEntityRecord
? 0UL
: 1UL;
MovementAuthorityVersion = 1UL;
+ ObjDescAuthorityVersion = 1UL;
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState);
}
@@ -258,6 +259,7 @@ public sealed class LiveEntityRecord
internal ulong VectorAuthorityVersion { get; private set; }
internal ulong VelocityAuthorityVersion { get; private set; }
internal ulong MovementAuthorityVersion { get; private set; }
+ internal ulong ObjDescAuthorityVersion { get; private set; }
///
/// Advances for every accepted CreateObject affecting this incarnation,
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
@@ -284,6 +286,15 @@ public sealed class LiveEntityRecord
internal bool ProjectionHydrationInProgress { get; set; }
internal ulong ProjectionHydrationCreateVersion { get; set; }
internal bool ProjectionHydrationRetryRequested { get; set; }
+ ///
+ /// A renderer mesh transaction can be re-entered by a newer accepted
+ /// ObjDesc. Keep that independent projection obligation on the exact
+ /// incarnation until the newest visual description publishes.
+ ///
+ internal bool AppearanceProjectionSynchronizationPending { get; set; }
+ internal bool AppearanceHydrationInProgress { get; set; }
+ internal ulong AppearanceHydrationVersion { get; set; }
+ internal bool AppearanceHydrationRetryRequested { get; set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool RuntimeComponentsTeardownCompleted { get; set; }
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
@@ -338,6 +349,14 @@ public sealed class LiveEntityRecord
VelocityAuthorityVersion++;
}
+ internal void AdvanceObjDescAuthority()
+ {
+ ObjDescAuthorityVersion++;
+ AppearanceProjectionSynchronizationPending = true;
+ if (AppearanceHydrationInProgress)
+ AppearanceHydrationRetryRequested = true;
+ }
+
internal void AdvanceCreateAuthority()
{
PositionAuthorityVersion++;
@@ -345,6 +364,7 @@ public sealed class LiveEntityRecord
VectorAuthorityVersion++;
VelocityAuthorityVersion++;
MovementAuthorityVersion++;
+ ObjDescAuthorityVersion++;
CreateIntegrationVersion++;
}
@@ -1547,7 +1567,12 @@ public sealed class LiveEntityRuntime
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
- if (applied) RefreshRecord(update.Guid, accepted);
+ if (applied)
+ {
+ RefreshRecord(update.Guid, accepted);
+ if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record))
+ record.AdvanceObjDescAuthority();
+ }
return applied;
}
@@ -1805,6 +1830,13 @@ public sealed class LiveEntityRuntime
&& ReferenceEquals(current, record)
&& current.MovementAuthorityVersion == authorityVersion;
+ internal bool IsCurrentObjDescAuthority(
+ LiveEntityRecord record,
+ ulong authorityVersion) =>
+ _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
+ && ReferenceEquals(current, record)
+ && current.ObjDescAuthorityVersion == authorityVersion;
+
///
/// Copies the canonical loaded root-object workset used by retail
/// CPhysics::UseTime. The record reference is the incarnation token;
@@ -2156,6 +2188,63 @@ public sealed class LiveEntityRuntime
return retry;
}
+ internal bool TryBeginAppearanceHydration(
+ LiveEntityRecord expectedRecord,
+ ulong expectedObjDescAuthorityVersion)
+ {
+ ArgumentNullException.ThrowIfNull(expectedRecord);
+ if (!expectedRecord.AppearanceProjectionSynchronizationPending
+ || !IsCurrentObjDescAuthority(
+ expectedRecord,
+ expectedObjDescAuthorityVersion))
+ {
+ return false;
+ }
+
+ expectedRecord.AppearanceProjectionSynchronizationPending = true;
+ if (expectedRecord.AppearanceHydrationInProgress)
+ {
+ expectedRecord.AppearanceHydrationRetryRequested = true;
+ return false;
+ }
+
+ expectedRecord.AppearanceHydrationInProgress = true;
+ expectedRecord.AppearanceHydrationVersion =
+ expectedObjDescAuthorityVersion;
+ expectedRecord.AppearanceHydrationRetryRequested = false;
+ return true;
+ }
+
+ internal bool EndAppearanceHydration(LiveEntityRecord expectedRecord)
+ {
+ ArgumentNullException.ThrowIfNull(expectedRecord);
+ bool retry = IsCurrentRecord(expectedRecord)
+ && (expectedRecord.AppearanceHydrationRetryRequested
+ || expectedRecord.ObjDescAuthorityVersion
+ != expectedRecord.AppearanceHydrationVersion);
+ expectedRecord.AppearanceHydrationInProgress = false;
+ expectedRecord.AppearanceHydrationVersion = 0UL;
+ expectedRecord.AppearanceHydrationRetryRequested = false;
+ return retry;
+ }
+
+ internal bool TryCompleteAppearanceProjectionSynchronization(
+ LiveEntityRecord expectedRecord,
+ ulong expectedObjDescAuthorityVersion)
+ {
+ ArgumentNullException.ThrowIfNull(expectedRecord);
+ if (!expectedRecord.AppearanceProjectionSynchronizationPending
+ || !IsCurrentObjDescAuthority(
+ expectedRecord,
+ expectedObjDescAuthorityVersion))
+ {
+ return false;
+ }
+
+ expectedRecord.AppearanceProjectionSynchronizationPending = false;
+ return true;
+ }
+
internal bool IsCurrentCreateIntegration(
LiveEntityRecord expectedRecord,
ulong expectedCreateIntegrationVersion) =>
@@ -2722,6 +2811,10 @@ public sealed class LiveEntityRuntime
record.ProjectionHydrationInProgress = false;
record.ProjectionHydrationCreateVersion = 0UL;
record.ProjectionHydrationRetryRequested = false;
+ record.AppearanceProjectionSynchronizationPending = false;
+ record.AppearanceHydrationInProgress = false;
+ record.AppearanceHydrationVersion = 0UL;
+ record.AppearanceHydrationRetryRequested = false;
record.WorldEntity = null;
}
finally
diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
index 5f1bf6e3..024114e8 100644
--- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs
@@ -433,6 +433,134 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
}
+ [Fact]
+ public void SpawnParentWithoutAnimationFrame_UsesRetailPlacementZero()
+ {
+ using var fixture = new ControllerFixture((_, _, _) =>
+ new ExactProjectionWithdrawalOutcome(
+ ExactProjectionWithdrawalDisposition.Completed,
+ Failure: null));
+ LiveEntityRecord parent = fixture.Spawn(0x70000270u, generation: 1);
+ WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
+ 0x70000271u,
+ generation: 1) with
+ {
+ Position = null,
+ ParentGuid = parent.ServerGuid,
+ ParentLocation = 0,
+ PlacementId = null,
+ PositionSequence = 0,
+ };
+ LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!;
+
+ fixture.Controller.OnSpawn(childSpawn);
+
+ var expected = new ParentAttachmentRelation(
+ parent.ServerGuid,
+ child.ServerGuid,
+ ParentLocation: 0,
+ PlacementId: 0,
+ ParentInstanceSequence: 0,
+ ChildPositionSequence: 0);
+ Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected));
+ fixture.Poses.Publish(parent.WorldEntity!, Array.Empty());
+ fixture.Controller.OnPosePublished(parent.ServerGuid);
+ Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
+ Assert.NotNull(child.WorldEntity);
+ }
+
+ [Fact]
+ public void ObjDesc_ReprojectsAttachedChildWithoutWorldPositionOrIdentityChange()
+ {
+ ControllerFixture? fixture = null;
+ fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
+ {
+ bool completed = fixture!.Live.WithdrawLiveEntityProjection(
+ record,
+ positionVersion,
+ projectionVersion);
+ return new(
+ completed
+ ? ExactProjectionWithdrawalDisposition.Completed
+ : ExactProjectionWithdrawalDisposition.Superseded,
+ Failure: null);
+ });
+ using (fixture)
+ {
+ LiveEntityRecord parent = fixture.Spawn(0x70000272u, generation: 1);
+ WorldSession.EntitySpawn childSpawn = ControllerFixture.SpawnData(
+ 0x70000273u,
+ generation: 1) with
+ {
+ Position = null,
+ ParentGuid = parent.ServerGuid,
+ ParentLocation = 0,
+ PlacementId = 0,
+ PositionSequence = 0,
+ };
+ LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!;
+ fixture.Controller.OnSpawn(childSpawn);
+ fixture.Poses.Publish(parent.WorldEntity!, Array.Empty());
+ fixture.Controller.OnPosePublished(parent.ServerGuid);
+ WorldEntity entity = child.WorldEntity!;
+ Assert.Null(entity.PaletteOverride);
+ WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData(
+ 0x70000274u,
+ generation: 1) with
+ {
+ Position = null,
+ ParentGuid = child.ServerGuid,
+ ParentLocation = 0,
+ PlacementId = 0,
+ PositionSequence = 0,
+ };
+ LiveEntityRecord grandchild = fixture.Live.RegisterLiveEntity(
+ grandchildSpawn).Record!;
+ fixture.Controller.OnSpawn(grandchildSpawn);
+ WorldEntity grandchildEntity = grandchild.WorldEntity!;
+ Assert.Equal(
+ LiveEntityProjectionKind.Attached,
+ grandchild.ProjectionKind);
+
+ Assert.True(fixture.Live.TryApplyObjDesc(
+ new ObjDescEvent.Parsed(
+ child.ServerGuid,
+ new CreateObject.ModelData(
+ BasePaletteId: 0x04000022u,
+ [new CreateObject.SubPaletteSwap(
+ 0x0F000033u,
+ Offset: 4,
+ Length: 8)],
+ Array.Empty(),
+ Array.Empty()),
+ InstanceSequence: 1,
+ ObjDescSequence: 1),
+ out _));
+ Assert.Null(child.Snapshot.Position);
+
+ Assert.True(fixture.Controller.TryApplyAttachedAppearance(
+ child,
+ child.ObjDescAuthorityVersion));
+
+ Assert.Same(entity, child.WorldEntity);
+ Assert.True(child.IsSpatiallyProjected);
+ Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind);
+ Assert.Equal((uint)0x04000022u, child.Snapshot.BasePaletteId);
+ Assert.NotNull(entity.PaletteOverride);
+ Assert.Equal(0x04000022u, entity.PaletteOverride!.BasePaletteId);
+ PaletteOverride.SubPaletteRange range =
+ Assert.Single(entity.PaletteOverride.SubPalettes);
+ Assert.Equal(0x0F000033u, range.SubPaletteId);
+ Assert.Equal((byte)4, range.Offset);
+ Assert.Equal((byte)8, range.Length);
+ Assert.Same(grandchildEntity, grandchild.WorldEntity);
+ Assert.True(grandchild.IsSpatiallyProjected);
+ Assert.Equal(
+ LiveEntityProjectionKind.Attached,
+ grandchild.ProjectionKind);
+ }
+ }
+
[Fact]
public void ChildWithoutSetup_StillCommitsLogicalParenting()
{
diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
index 354a888e..d16db0a7 100644
--- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
+++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs
@@ -22,6 +22,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("RehydrateServerEntitiesForLandblock")]
[InlineData("TryInitializeLiveCenter")]
[InlineData("CompleteLiveEntityReady")]
+ [InlineData("OnLiveAppearanceUpdated")]
+ [InlineData("OnLiveEntityPickedUp")]
+ [InlineData("OnLiveParentUpdated")]
+ [InlineData("TryAcceptParentForRender")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
index 9cff0e04..0ff1c3a5 100644
--- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
+++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
@@ -12,6 +12,25 @@ namespace AcDream.App.Tests.World;
public sealed class LiveEntityHydrationControllerTests
{
+ [Fact]
+ public void ParentAcceptanceBridge_IsFailFastAndSingleAssignment()
+ {
+ var bridge = new DeferredLiveEntityParentAcceptance();
+ var update = new ParentEvent.Parsed(1, 2, 3, 4, 5, 6);
+ Assert.Throws(() => bridge.TryAccept(update));
+
+ ParentEvent.Parsed accepted = default;
+ bridge.Bind(value =>
+ {
+ accepted = value;
+ return true;
+ });
+
+ Assert.True(bridge.TryAccept(update));
+ Assert.Equal(update, accepted);
+ Assert.Throws(() => bridge.Bind(_ => false));
+ }
+
[Fact]
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
{
@@ -93,10 +112,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityRecord record = fixture.Record;
Assert.Null(record.WorldEntity);
- Assert.True(fixture.Controller.ApplyAppearance(
- record,
- record.Snapshot,
- appearanceUpdate: null));
+ Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.NotNull(record.WorldEntity);
Assert.Equal(2, fixture.Materializer.Calls.Count);
@@ -105,6 +121,324 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(1, fixture.Ready.PublishCount);
}
+ [Fact]
+ public void AppearanceUpdate_ReplacesOnlyProjectionAndPreservesExactOwners()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord record = fixture.Record;
+ WorldEntity entity = record.WorldEntity!;
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+
+ Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+
+ Assert.Same(record, fixture.Record);
+ Assert.Same(entity, record.WorldEntity);
+ Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
+ Assert.False(record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal(LiveProjectionPurpose.AppearanceMutation, fixture.Materializer.Calls[^1].Purpose);
+ Assert.Equal(1, fixture.Resources.RegisterCount);
+ Assert.Equal(0, fixture.Resources.UnregisterCount);
+ Assert.Equal(1, applied);
+ }
+
+ [Fact]
+ public void NestedAppearanceUpdate_ReplaysNewestCanonicalSnapshotAfterOuterPublication()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+ bool nested = false;
+ bool nestedResult = true;
+ fixture.Materializer.BeforeMaterialize = (_, _) =>
+ {
+ if (fixture.Materializer.Calls[^1].Purpose is not LiveProjectionPurpose.AppearanceMutation
+ || nested)
+ {
+ return;
+ }
+
+ nested = true;
+ nestedResult = fixture.Controller.OnAppearance(ObjDesc(3, 0x04000033u));
+ };
+
+ Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+
+ Assert.False(nestedResult);
+ Assert.Equal((uint)0x04000033u, fixture.Record.Snapshot.BasePaletteId);
+ Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal(
+ 2,
+ fixture.Materializer.Calls.Count(call =>
+ call.Purpose is LiveProjectionPurpose.AppearanceMutation));
+ Assert.Equal(1, applied);
+ }
+
+ [Fact]
+ public void AppearancePublicationFailure_RetainsObligationForLandblockRetry()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+ fixture.Materializer.ThrowAfterMaterializePurposeOnce =
+ LiveProjectionPurpose.AppearanceMutation;
+
+ Assert.Throws(() =>
+ fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+ Assert.True(fixture.Record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal(0, applied);
+
+ fixture.Controller.OnLandblockLoaded(Cell);
+
+ Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
+ Assert.Equal(1, fixture.Resources.RegisterCount);
+ Assert.Equal(1, applied);
+ }
+
+ [Fact]
+ public void AttachedAppearance_UsesRelationshipProjectionWithoutWorldPosition()
+ {
+ const uint parentGuid = 0x70000002u;
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord record = fixture.Record;
+ WorldEntity entity = record.WorldEntity!;
+ Assert.True(fixture.Runtime.TryApplyCreateParent(
+ new CreateParentUpdate(Guid, parentGuid, 1, 0, 1, 2),
+ out _));
+ Assert.True(fixture.Runtime.CommitStagedParent(
+ new ParentAttachmentRelation(
+ parentGuid,
+ Guid,
+ ParentLocation: 1,
+ PlacementId: 0,
+ ParentInstanceSequence: 0,
+ ChildPositionSequence: 2),
+ out _));
+ record.ProjectionKind = LiveEntityProjectionKind.Attached;
+ record.FullCellId = 0u;
+ int materializerCalls = fixture.Materializer.Calls.Count;
+ int relationshipCalls = 0;
+ int applied = 0;
+ fixture.Relationships.ApplyAttachedAppearance = (expected, version) =>
+ {
+ relationshipCalls++;
+ Assert.Same(record, expected);
+ Assert.True(fixture.Runtime.IsCurrentObjDescAuthority(expected, version));
+ Assert.Null(expected.Snapshot.Position);
+ Assert.Equal((uint)0x04000022u, expected.Snapshot.BasePaletteId);
+ return true;
+ };
+ fixture.Controller.AppearanceApplied += _ => applied++;
+
+ Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+
+ Assert.Same(entity, record.WorldEntity);
+ Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind);
+ Assert.False(record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal(materializerCalls, fixture.Materializer.Calls.Count);
+ Assert.Equal(1, relationshipCalls);
+ Assert.Equal(1, applied);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(0)]
+ public void StaleOrEqualAppearance_HasNoProjectionOrNotificationSideEffects(
+ ushort sequence)
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ int calls = fixture.Materializer.Calls.Count;
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+
+ Assert.False(fixture.Controller.OnAppearance(
+ ObjDesc(sequence, 0x04000022u)));
+
+ Assert.Equal(calls, fixture.Materializer.Calls.Count);
+ Assert.Equal(0, applied);
+ Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
+ Assert.Null(fixture.Record.Snapshot.BasePaletteId);
+ }
+
+ [Fact]
+ public void AppearanceSequenceWrap_PublishesExactlyOnce()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
+ spawn = spawn with
+ {
+ Physics = spawn.Physics!.Value with
+ {
+ Timestamps = spawn.Physics.Value.Timestamps with
+ {
+ ObjDesc = 0xFFFF,
+ },
+ },
+ };
+ fixture.Controller.OnCreate(spawn);
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+
+ Assert.True(fixture.Controller.OnAppearance(ObjDesc(0, 0x04000022u)));
+
+ Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
+ Assert.Equal(1, applied);
+ }
+
+ [Fact]
+ public void FailedAppearance_IsClearedAtGenerationReplacementAndCannotLeak()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord retired = fixture.Record;
+ fixture.Materializer.SkipMaterializationCount = 1;
+ Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+ Assert.True(retired.AppearanceProjectionSynchronizationPending);
+
+ fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
+ LiveEntityRecord replacement = fixture.Record;
+ fixture.Controller.OnLandblockLoaded(Cell);
+
+ Assert.NotSame(retired, replacement);
+ Assert.False(retired.AppearanceProjectionSynchronizationPending);
+ Assert.False(retired.AppearanceHydrationInProgress);
+ Assert.False(replacement.AppearanceProjectionSynchronizationPending);
+ Assert.Null(replacement.Snapshot.BasePaletteId);
+ }
+
+ [Fact]
+ public void Pickup_LeavesWorldWithoutEndingLogicalOrResourceIdentity()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord record = fixture.Record;
+ WorldEntity entity = record.WorldEntity!;
+ fixture.Relationships.OnUnparentAction = _ =>
+ fixture.Runtime.WithdrawLiveEntityProjection(record)
+ ? ChildUnparentDisposition.Completed
+ : ChildUnparentDisposition.Superseded;
+
+ Assert.True(fixture.Controller.OnPickup(
+ new PickupEvent.Parsed(Guid, 1, 2)));
+
+ Assert.Same(record, fixture.Record);
+ Assert.Same(entity, record.WorldEntity);
+ Assert.Equal(0u, record.FullCellId);
+ Assert.False(record.IsSpatiallyProjected);
+ Assert.True(record.ResourcesRegistered);
+ Assert.Equal(1, fixture.Resources.RegisterCount);
+ Assert.Equal(0, fixture.Resources.UnregisterCount);
+ Assert.Contains($"unparent:{Guid:X8}", fixture.Operations);
+ }
+
+ [Fact]
+ public void PositionAfterPickup_ReentersWithSameEntityBodyAndResources()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord record = fixture.Record;
+ WorldEntity entity = record.WorldEntity!;
+ var body = new PhysicsBody();
+ record.PhysicsBody = body;
+ fixture.Relationships.OnUnparentAction = _ =>
+ fixture.Runtime.WithdrawLiveEntityProjection(record)
+ ? ChildUnparentDisposition.Completed
+ : ChildUnparentDisposition.Superseded;
+ Assert.True(fixture.Controller.OnPickup(
+ new PickupEvent.Parsed(Guid, 1, 2)));
+ int applied = 0;
+ fixture.Controller.AppearanceApplied += _ => applied++;
+ Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
+ Assert.True(record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal(0, applied);
+
+ Assert.True(fixture.Runtime.TryApplyPosition(
+ new WorldSession.EntityPositionUpdate(
+ Guid,
+ new CreateObject.ServerPosition(
+ Cell, 14f, 12f, 6f, 1f, 0f, 0f, 0f),
+ Velocity: null,
+ PlacementId: 0,
+ IsGrounded: true,
+ InstanceSequence: 1,
+ PositionSequence: 3,
+ TeleportSequence: 0,
+ ForcePositionSequence: 0),
+ isLocalPlayer: false,
+ forcePositionRotation: null,
+ currentLocalVelocity: null,
+ out _,
+ out WorldSession.EntitySpawn accepted,
+ out _));
+ ulong positionAuthority = record.PositionAuthorityVersion;
+
+ Assert.True(fixture.Controller.RecoverProjection(
+ record,
+ positionAuthority,
+ accepted));
+
+ Assert.Same(entity, record.WorldEntity);
+ Assert.Same(body, record.PhysicsBody);
+ Assert.True(record.IsSpatiallyProjected);
+ Assert.False(record.AppearanceProjectionSynchronizationPending);
+ Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
+ Assert.Equal(1, applied);
+ Assert.Equal(1, fixture.Resources.RegisterCount);
+ Assert.Equal(0, fixture.Resources.UnregisterCount);
+ }
+
+ [Fact]
+ public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ const uint parentGuid = 0x70000002u;
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ fixture.Runtime.RegisterLiveEntity(
+ Spawn(Generation: 4, PositionSequence: 1) with { Guid = parentGuid });
+ bool accepted = false;
+ fixture.Relationships.OnParentAction = update =>
+ accepted = fixture.Controller.TryAcceptParentForProjection(update);
+ var update = new ParentEvent.Parsed(
+ parentGuid,
+ Guid,
+ ParentLocation: 1,
+ PlacementId: 0,
+ ParentInstanceSequence: 4,
+ ChildPositionSequence: 2);
+
+ fixture.Controller.OnParent(update);
+
+ Assert.True(accepted);
+ Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
+ Assert.Contains($"parent:{Guid:X8}", fixture.Operations);
+ }
+
+ [Fact]
+ public void SameGenerationCreateParent_AdvancesSharedPositionAndPublishesRelation()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ var update = new CreateParentUpdate(
+ Guid,
+ ParentGuid: 0x70000002u,
+ ParentLocation: 3,
+ PlacementId: 0,
+ ChildInstanceSequence: 1,
+ ChildPositionSequence: 2);
+
+ Assert.True(fixture.Controller.OnCreateParentAccepted(update));
+
+ Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
+ Assert.Contains($"create-parent:{Guid:X8}", fixture.Operations);
+ }
+
[Fact]
public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce()
{
@@ -805,9 +1139,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityProjectionKind.Attached);
Assert.Same(retained, attached);
fixture.Controller.OnEntityReady(
- new LiveEntityReadyCandidate(
- record,
- record.CreateIntegrationVersion));
+ LiveEntityReadyCandidate.Capture(record));
};
bool refreshed = false;
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
@@ -880,9 +1212,8 @@ public sealed class LiveEntityHydrationControllerTests
},
LiveEntityProjectionKind.Attached);
Assert.NotNull(attached);
- fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate(
- record,
- record.CreateIntegrationVersion));
+ fixture.Controller.OnEntityReady(
+ LiveEntityReadyCandidate.Capture(record));
};
fixture.Controller.OnCreate(CelllessSpawn(
@@ -945,7 +1276,8 @@ public sealed class LiveEntityHydrationControllerTests
_ => { present++; return Stage(1); },
_ => { replay++; return Stage(2); });
- Assert.False(publisher.Publish(expected));
+ Assert.False(publisher.Publish(
+ LiveEntityReadyCandidate.Capture(expected)));
Assert.Equal(expectedPrepare, prepare);
Assert.Equal(expectedPresent, present);
Assert.Equal(expectedReplay, replay);
@@ -953,6 +1285,52 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
}
+ [Theory]
+ [InlineData(0, 1, 0, 0)]
+ [InlineData(1, 1, 1, 0)]
+ [InlineData(2, 1, 1, 1)]
+ public void ReadyPublisher_RejectsSameIdentityProjectionChangeBetweenStages(
+ int withdrawalStage,
+ int expectedPrepare,
+ int expectedPresent,
+ int expectedReplay)
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord expected = fixture.Record;
+ LiveEntityReadyCandidate candidate =
+ LiveEntityReadyCandidate.Capture(expected);
+ int prepare = 0;
+ int present = 0;
+ int replay = 0;
+ bool withdrawn = false;
+ bool Stage(int stage)
+ {
+ if (!withdrawn && withdrawalStage == stage)
+ {
+ withdrawn = true;
+ Assert.True(fixture.Runtime.TryApplyPickup(
+ new PickupEvent.Parsed(Guid, 1, 2),
+ out _));
+ Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(expected));
+ }
+ return true;
+ }
+
+ var publisher = new LiveEntityReadyPublisher(
+ fixture.Runtime,
+ _ => { prepare++; return Stage(0); },
+ _ => { present++; return Stage(1); },
+ _ => { replay++; return Stage(2); });
+
+ Assert.False(publisher.Publish(candidate));
+ Assert.Equal(expectedPrepare, prepare);
+ Assert.Equal(expectedPresent, present);
+ Assert.Equal(expectedReplay, replay);
+ Assert.Same(expected, fixture.Record);
+ Assert.False(expected.IsSpatiallyProjected);
+ }
+
[Fact]
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
{
@@ -970,9 +1348,35 @@ public sealed class LiveEntityHydrationControllerTests
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
fixture.Runtime,
- record,
- capturedCreateIntegrationVersion,
- entity,
+ LiveEntityReadyCandidate.Capture(record) with
+ {
+ CreateIntegrationVersion = capturedCreateIntegrationVersion,
+ WorldEntity = entity,
+ },
+ _ => published = true));
+ Assert.False(published);
+ }
+
+ [Fact]
+ public void EquippedChildReadyCandidate_RejectsPickupWithdrawalByPoseCallback()
+ {
+ using var fixture = new Fixture(originKnown: true);
+ fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
+ LiveEntityRecord record = fixture.Record;
+ LiveEntityReadyCandidate candidate =
+ LiveEntityReadyCandidate.Capture(record);
+
+ // Models ProjectionPoseReady accepting Pickup and withdrawing the
+ // retained projection before EntityReady is emitted.
+ Assert.True(fixture.Runtime.TryApplyPickup(
+ new PickupEvent.Parsed(Guid, 1, 2),
+ out _));
+ Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record));
+ bool published = false;
+
+ Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
+ fixture.Runtime,
+ candidate,
_ => published = true));
Assert.False(published);
}
@@ -1256,14 +1660,46 @@ public sealed class LiveEntityHydrationControllerTests
}
private sealed class RecordingRelationships(List operations)
- : ILiveEntitySpawnRelationshipSink
+ : ILiveEntityRelationshipProjection
{
public Action? OnSpawnAction { get; set; }
+ public Action? OnParentAction { get; set; }
+ public Action? OnCreateParentAction { get; set; }
+ public Func? OnUnparentAction { get; set; }
+ public Func? ApplyAttachedAppearance { get; set; }
public void OnSpawn(WorldSession.EntitySpawn spawn)
{
operations.Add($"relationship:{spawn.InstanceSequence}");
OnSpawnAction?.Invoke(spawn);
}
+
+ public void OnParent(ParentEvent.Parsed update)
+ {
+ operations.Add($"parent:{update.ChildGuid:X8}");
+ OnParentAction?.Invoke(update);
+ }
+
+ public void OnCreateParentAccepted(CreateParentUpdate update)
+ {
+ operations.Add($"create-parent:{update.ChildGuid:X8}");
+ OnCreateParentAction?.Invoke(update);
+ }
+
+ public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid)
+ {
+ operations.Add($"unparent:{childGuid:X8}");
+ return OnUnparentAction?.Invoke(childGuid)
+ ?? ChildUnparentDisposition.Completed;
+ }
+
+ public bool TryApplyAttachedAppearance(
+ LiveEntityRecord record,
+ ulong objDescAuthorityVersion)
+ {
+ operations.Add($"attached-appearance:{record.ServerGuid:X8}");
+ return ApplyAttachedAppearance?.Invoke(record, objDescAuthorityVersion)
+ ?? false;
+ }
}
private sealed class RecordingReadyPublisher(List operations)
@@ -1272,8 +1708,9 @@ public sealed class LiveEntityHydrationControllerTests
public int PublishCount { get; private set; }
public int FailPublishCount { get; set; }
public Action? BeforePublish { get; set; }
- public bool Publish(LiveEntityRecord expectedRecord)
+ public bool Publish(LiveEntityReadyCandidate candidate)
{
+ LiveEntityRecord expectedRecord = candidate.Record;
PublishCount++;
operations.Add("ready");
BeforePublish?.Invoke(expectedRecord);
@@ -1357,6 +1794,19 @@ public sealed class LiveEntityHydrationControllerTests
Physics: physics);
}
+ private static ObjDescEvent.Parsed ObjDesc(
+ ushort sequence,
+ uint basePaletteId) =>
+ new(
+ Guid,
+ new CreateObject.ModelData(
+ basePaletteId,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty()),
+ InstanceSequence: 1,
+ ObjDescSequence: sequence);
+
private static WorldSession.EntitySpawn CelllessSpawn(
ushort PositionSequence,
uint? parentGuid)