From fe5514967cf564a9d6e591e7a2c77ebef3ddd91b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 18:10:07 +0200 Subject: [PATCH] refactor(world): extract live appearance and parenting Move ObjDesc, Parent, Pickup, child-ready, and retained projection recovery into LiveEntityHydrationController while preserving retail timestamp and leave-world semantics. Pin ready publication to exact projection authority and restore attached descendant chains synchronously. --- ...-07-13-retail-projectile-vfx-pseudocode.md | 7 + .../EquippedChildRenderController.cs | 81 ++- src/AcDream.App/Rendering/GameWindow.cs | 123 +---- .../World/LiveEntityHydrationController.cs | 315 ++++++++++-- .../World/LiveEntityHydrationPorts.cs | 62 ++- src/AcDream.App/World/LiveEntityRuntime.cs | 95 +++- .../EquippedChildProjectionWithdrawalTests.cs | 128 +++++ .../GameWindowLiveEntityCompositionTests.cs | 4 + .../LiveEntityHydrationControllerTests.cs | 482 +++++++++++++++++- 9 files changed, 1098 insertions(+), 199 deletions(-) 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)