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.
This commit is contained in:
Erik 2026-07-21 18:10:07 +02:00
parent 40352d5a7a
commit fe5514967c
9 changed files with 1098 additions and 199 deletions

View file

@ -204,6 +204,13 @@ on ParentEvent(parent, child):
strictly advance the child's Position timestamp strictly advance the child's Position timestamp
apply parent + placement without creating a second position counter 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): on PickupEvent(object):
require the exact live Instance require the exact live Instance
strictly advance the shared Position timestamp strictly advance the shared Position timestamp

View file

@ -98,9 +98,12 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock) lock (_datLock)
{ {
if (spawn.ParentGuid is { } parentGuid and not 0 if (spawn.ParentGuid is { } parentGuid and not 0
&& spawn.ParentLocation is { } parentLocation && spawn.ParentLocation is { } parentLocation)
&& spawn.PlacementId is { } placementId)
{ {
// 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( Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid, parentGuid,
spawn.Guid, spawn.Guid,
@ -118,6 +121,51 @@ public sealed class EquippedChildRenderController : IDisposable
} }
} }
/// <summary>
/// Retail <c>SmartBox::UpdateVisualDesc @ 0x00451F40</c> 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.
/// </summary>
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);
}
}
/// <summary> /// <summary>
/// Completes attachment projection after a root world entity has been /// Completes attachment projection after a root world entity has been
/// registered. Relation acceptance intentionally happens before root /// registered. Relation acceptance intentionally happens before root
@ -448,46 +496,31 @@ public sealed class EquippedChildRenderController : IDisposable
// CPhysicsObj::set_hidden (0x00514C60). // CPhysicsObj::set_hidden (0x00514C60).
_liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true); _liveEntities.SetAttachedChildNoDraw(childGuid, noDraw: true);
} }
ulong readyCreateIntegrationVersion = childRecord.CreateIntegrationVersion;
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose); PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine( Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " + $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}"); $"location={parentLocation} placement={placement}");
Relations.MarkProjected(pending, candidateKind); Relations.MarkProjected(pending, candidateKind);
LiveEntityReadyCandidate readyCandidate =
LiveEntityReadyCandidate.Capture(childRecord);
ProjectionPoseReady?.Invoke(childGuid); ProjectionPoseReady?.Invoke(childGuid);
return PublishEntityReadyExact( return PublishEntityReadyExact(
_liveEntities, _liveEntities,
childRecord, readyCandidate,
readyCreateIntegrationVersion,
entity,
EntityReady); EntityReady);
} }
internal static bool PublishEntityReadyExact( internal static bool PublishEntityReadyExact(
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
LiveEntityRecord expectedRecord, LiveEntityReadyCandidate candidate,
ulong expectedCreateIntegrationVersion,
WorldEntity expectedEntity,
Action<LiveEntityReadyCandidate>? publish) Action<LiveEntityReadyCandidate>? publish)
{ {
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(expectedRecord); if (!candidate.IsCurrent(runtime))
ArgumentNullException.ThrowIfNull(expectedEntity);
if (!runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion)
|| !ReferenceEquals(expectedRecord.WorldEntity, expectedEntity))
{
return false; return false;
}
publish?.Invoke(new LiveEntityReadyCandidate( publish?.Invoke(candidate);
expectedRecord, return candidate.IsCurrent(runtime);
expectedCreateIntegrationVersion));
return runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion)
&& ReferenceEquals(expectedRecord.WorldEntity, expectedEntity);
} }
private void PublishChildPose( private void PublishChildPose(

View file

@ -749,8 +749,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// guid. Captured before the renderability gate so no-position inventory / /// guid. Captured before the renderability gate so no-position inventory /
/// parented children retain Setup and parent metadata; hydration rereads /// parented children retain Setup and parent metadata; hydration rereads
/// this canonical snapshot after every relationship callback. /// this canonical snapshot after every relationship callback.
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion /// <see cref="LiveEntityHydrationController.OnAppearance"/> reuses the cached
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
/// updated visuals.
/// </summary> /// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns => private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap; _liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
@ -1750,6 +1751,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
item, container, placement, amount), item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid)); requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1). // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi) if (_options.RetailUi)
{ {
@ -2270,13 +2273,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_lightingSink!, _lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId)); setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController( _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!, _dats!,
_datLock, _datLock,
Objects, Objects,
_liveEntities, _liveEntities,
_effectPoses, _effectPoses,
TryAcceptParentForRender, parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) => (childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact( _liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord, childRecord,
@ -2567,8 +2571,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Objects, Objects,
_datLock, _datLock,
projectionMaterializer, projectionMaterializer,
new AcDream.App.World.DelegateLiveEntitySpawnRelationshipSink( new AcDream.App.World.LiveEntityRelationshipProjection(
_equippedChildRenderer.OnSpawn), _equippedChildRenderer),
new AcDream.App.World.LiveEntityReadyPublisher( new AcDream.App.World.LiveEntityReadyPublisher(
_liveEntities, _liveEntities,
_entityEffects!, _entityEffects!,
@ -2578,11 +2582,17 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
PublishLocalPhysicsTimestamps, PublishLocalPhysicsTimestamps,
() => _playerServerGuid, () => _playerServerGuid,
_options.DumpLiveSpawns ? Console.WriteLine : null); _options.DumpLiveSpawns ? Console.WriteLine : null);
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind( networkUpdateBridge.Bind(
new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink( new AcDream.App.World.DelegateLiveEntityNetworkUpdateSink(
RouteSameGenerationCreateObject)); RouteSameGenerationCreateObject));
_equippedChildRenderer.EntityReady += candidate => _equippedChildRenderer.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate); _liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid =>
{
if (guid == _playerServerGuid)
_paperdollDollDirty = true;
};
// Phase 4.7: optional live-mode startup. Connect to the ACE server, // Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream // 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( Deleted: deletion => _inboundEntityEvents.Run(
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
PickedUp: pickup => _inboundEntityEvents.Run( PickedUp: pickup => _inboundEntityEvents.Run(
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)), _liveEntityHydration!, pickup,
static (hydration, value) => hydration.OnPickup(value)),
MotionUpdated: motion => _inboundEntityEvents.Run( MotionUpdated: motion => _inboundEntityEvents.Run(
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)), this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
PositionUpdated: position => _inboundEntityEvents.Run( PositionUpdated: position => _inboundEntityEvents.Run(
@ -2833,11 +2844,13 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
StateUpdated: state => _inboundEntityEvents.Run( StateUpdated: state => _inboundEntityEvents.Run(
this, state, static (window, value) => window.OnLiveStateUpdated(value)), this, state, static (window, value) => window.OnLiveStateUpdated(value)),
ParentUpdated: parent => _inboundEntityEvents.Run( ParentUpdated: parent => _inboundEntityEvents.Run(
this, parent, static (window, value) => window.OnLiveParentUpdated(value)), _liveEntityHydration!, parent,
static (hydration, value) => hydration.OnParent(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run( TeleportStarted: teleport => _inboundEntityEvents.Run(
this, teleport, static (window, value) => window.OnTeleportStarted(value)), this, teleport, static (window, value) => window.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run( AppearanceUpdated: appearance => _inboundEntityEvents.Run(
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)), _liveEntityHydration!, appearance,
static (hydration, value) => hydration.OnAppearance(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run( PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)), this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run( PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
@ -3037,46 +3050,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
candidate.Generation)); candidate.Generation));
} }
/// <summary>
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — 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 <c>SmartBox::UpdateVisualDesc</c> calls
/// <c>CPhysicsObj::DoObjDescChangesFromDefault</c> on the existing object;
/// accordingly, entity identity and all animation/movement/physics state
/// survive while MeshRefs, palette, and part overrides are replaced.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / /// 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); _entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
} }
OnLiveAppearanceUpdated(refresh.Appearance); _liveEntityHydration!.OnAppearance(refresh.Appearance);
if (refresh.Parent is { } parent if (refresh.Parent is { } parent)
&& _liveEntities!.TryApplyCreateParent(parent, out _)) _liveEntityHydration.OnCreateParentAccepted(parent);
{
_equippedChildRenderer?.OnCreateParentAccepted(parent);
}
else if (refresh.Position is { } position) else if (refresh.Position is { } position)
{ {
OnLivePositionUpdated(position); OnLivePositionUpdated(position);
} }
else if (refresh.Pickup is { } pickup) else if (refresh.Pickup is { } pickup)
{ {
OnLiveEntityPickedUp(pickup); _liveEntityHydration.OnPickup(pickup);
} }
if (refresh.Movement is { } movement) if (refresh.Movement is { } movement)
@ -3220,49 +3190,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
OnLiveVectorUpdated(refresh.Vector); OnLiveVectorUpdated(refresh.Vector);
} }
/// <summary>
/// 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.
/// </summary>
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}");
}
}
/// <summary>
/// ParentEvent may precede either CreateObject. The focused child renderer
/// retains it; its realization callback performs the canonical timestamp
/// acceptance immediately before changing the projection.
/// </summary>
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( private void PublishLocalPhysicsTimestamps(
uint guid, uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps) AcDream.App.World.AcceptedPhysicsTimestamps timestamps)

View file

@ -1,6 +1,8 @@
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.World;
namespace AcDream.App.World; namespace AcDream.App.World;
@ -29,19 +31,55 @@ internal interface ILiveEntityProjectionMaterializer
void ResetSessionState(); void ResetSessionState();
} }
internal interface ILiveEntitySpawnRelationshipSink internal interface ILiveEntityRelationshipProjection
{ {
void OnSpawn(WorldSession.EntitySpawn spawn); 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 internal interface ILiveEntityReadyPublisher
{ {
bool Publish(LiveEntityRecord expectedRecord); bool Publish(LiveEntityReadyCandidate candidate);
} }
internal readonly record struct LiveEntityReadyCandidate( internal readonly record struct LiveEntityReadyCandidate(
LiveEntityRecord Record, 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 internal interface ILiveEntityNetworkUpdateSink
{ {
@ -104,7 +142,7 @@ internal sealed class LiveEntityHydrationController
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly object _datLock; private readonly object _datLock;
private readonly ILiveEntityProjectionMaterializer _materializer; private readonly ILiveEntityProjectionMaterializer _materializer;
private readonly ILiveEntitySpawnRelationshipSink _relationships; private readonly ILiveEntityRelationshipProjection _relationships;
private readonly ILiveEntityReadyPublisher _ready; private readonly ILiveEntityReadyPublisher _ready;
private readonly ILiveEntityWorldOriginCoordinator _origin; private readonly ILiveEntityWorldOriginCoordinator _origin;
private readonly ILiveEntityNetworkUpdateSink _networkUpdates; private readonly ILiveEntityNetworkUpdateSink _networkUpdates;
@ -117,7 +155,7 @@ internal sealed class LiveEntityHydrationController
ClientObjectTable objects, ClientObjectTable objects,
object datLock, object datLock,
ILiveEntityProjectionMaterializer materializer, ILiveEntityProjectionMaterializer materializer,
ILiveEntitySpawnRelationshipSink relationships, ILiveEntityRelationshipProjection relationships,
ILiveEntityReadyPublisher ready, ILiveEntityReadyPublisher ready,
ILiveEntityWorldOriginCoordinator origin, ILiveEntityWorldOriginCoordinator origin,
ILiveEntityNetworkUpdateSink networkUpdates, ILiveEntityNetworkUpdateSink networkUpdates,
@ -139,6 +177,8 @@ internal sealed class LiveEntityHydrationController
_diagnostic = diagnostic; _diagnostic = diagnostic;
} }
internal event Action<uint>? AppearanceApplied;
public void OnCreate(WorldSession.EntitySpawn spawn) public void OnCreate(WorldSession.EntitySpawn spawn)
{ {
// DatCollection uses one mutable reader cursor shared with streaming. // DatCollection uses one mutable reader cursor shared with streaming.
@ -228,6 +268,14 @@ internal sealed class LiveEntityHydrationController
expectedCreateIntegrationVersion: expectedCreateIntegrationVersion:
createIntegrationVersion); createIntegrationVersion);
} }
if (_runtime.IsCurrentRecord(record)
&& record.AppearanceProjectionSynchronizationPending)
{
SynchronizeAppearance(
record,
record.ObjDescAuthorityVersion);
}
} }
} }
catch (Exception applyFailure) catch (Exception applyFailure)
@ -248,6 +296,68 @@ internal sealed class LiveEntityHydrationController
} }
} }
/// <summary>
/// Applies retail <c>SmartBox::UpdateVisualDesc @ 0x00451F40</c> 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.
/// </summary>
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);
}
}
/// <summary>
/// Retail <c>DoPickupEvent @ 0x00452240</c>: consume the shared Position
/// timestamp, unparent, and leave world without destroying the logical
/// object or any incarnation-scoped owner.
/// </summary>
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;
}
/// <summary>
/// Parent events may precede either CreateObject. The relationship owner
/// retains wire order and invokes <see cref="TryAcceptParentForProjection"/>
/// only when both exact incarnations are addressable.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Reprojects retained live objects after their landblock is loaded. An /// Reprojects retained live objects after their landblock is loaded. An
/// fully hydrated record only rebuckets. A retained partial projection /// fully hydrated record only rebuckets. A retained partial projection
@ -263,7 +373,8 @@ internal sealed class LiveEntityHydrationController
LiveEntityRecord[] records = _runtime.Records LiveEntityRecord[] records = _runtime.Records
.Where(record => (record.ServerGuid != _playerGuid() .Where(record => (record.ServerGuid != _playerGuid()
|| !record.InitialHydrationCompleted || !record.InitialHydrationCompleted
|| record.CreateProjectionSynchronizationPending) || record.CreateProjectionSynchronizationPending
|| record.AppearanceProjectionSynchronizationPending)
&& record.ProjectionCellId != 0 && record.ProjectionCellId != 0
&& ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
&& record.Snapshot.SetupTableId is not null) && record.Snapshot.SetupTableId is not null)
@ -279,6 +390,9 @@ internal sealed class LiveEntityHydrationController
if (!_runtime.IsCurrentRecord(record)) if (!_runtime.IsCurrentRecord(record))
continue; continue;
ulong projectedObjDescVersion = record.ObjDescAuthorityVersion;
bool projectedCanonicalAppearance = false;
if (record.CreateProjectionSynchronizationPending) if (record.CreateProjectionSynchronizationPending)
{ {
if (ProjectExact( if (ProjectExact(
@ -287,6 +401,7 @@ internal sealed class LiveEntityHydrationController
LiveProjectionPurpose.CreateSupersessionRecovery)) LiveProjectionPurpose.CreateSupersessionRecovery))
{ {
projected++; projected++;
projectedCanonicalAppearance = true;
} }
} }
else if (record.WorldEntity is not null else if (record.WorldEntity is not null
@ -303,6 +418,24 @@ internal sealed class LiveEntityHydrationController
record, record,
record.Snapshot, record.Snapshot,
LiveProjectionPurpose.SpatialRecovery)) LiveProjectionPurpose.SpatialRecovery))
{
projected++;
projectedCanonicalAppearance = true;
}
if (projectedCanonicalAppearance
&& record.AppearanceProjectionSynchronizationPending)
{
CompleteAppearanceProjectionSynchronization(
record,
projectedObjDescVersion);
}
if (_runtime.IsCurrentRecord(record)
&& record.AppearanceProjectionSynchronizationPending
&& SynchronizeAppearance(
record,
record.ObjDescAuthorityVersion))
{ {
projected++; projected++;
} }
@ -353,7 +486,8 @@ internal sealed class LiveEntityHydrationController
return false; return false;
} }
return ProjectExact( ulong projectedObjDescVersion = expectedRecord.ObjDescAuthorityVersion;
bool projected = ProjectExact(
expectedRecord, expectedRecord,
acceptedSpawn, acceptedSpawn,
expectedRecord.CreateProjectionSynchronizationPending expectedRecord.CreateProjectionSynchronizationPending
@ -362,49 +496,128 @@ internal sealed class LiveEntityHydrationController
&& _runtime.IsCurrentPositionAuthority( && _runtime.IsCurrentPositionAuthority(
expectedRecord, expectedRecord,
positionAuthorityVersion); positionAuthorityVersion);
} if (!projected)
} return false;
public bool ApplyAppearance( if (expectedRecord.AppearanceProjectionSynchronizationPending)
LiveEntityRecord expectedRecord, {
WorldSession.EntitySpawn acceptedSpawn, if (_runtime.IsCurrentObjDescAuthority(
LiveEntityAppearanceUpdateState? appearanceUpdate) expectedRecord,
{ projectedObjDescVersion))
ArgumentNullException.ThrowIfNull(expectedRecord); {
lock (_datLock) CompleteAppearanceProjectionSynchronization(
{ expectedRecord,
return ProjectExact( projectedObjDescVersion);
}
else if (!SynchronizeAppearance(
expectedRecord,
expectedRecord.ObjDescAuthorityVersion))
{
return false;
}
}
return _runtime.IsCurrentPositionAuthority(
expectedRecord, expectedRecord,
acceptedSpawn, positionAuthorityVersion);
appearanceUpdate is null
? LiveProjectionPurpose.SpatialRecovery
: LiveProjectionPurpose.AppearanceMutation,
appearanceUpdate);
} }
} }
public bool OnEntityReady(LiveEntityReadyCandidate candidate) public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{ {
if (!PublishReady( if (!PublishReady(candidate))
candidate.Record,
candidate.CreateIntegrationVersion))
{ {
return false; 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( || _runtime.TryCompleteCreateProjectionSynchronization(
candidate.Record, candidate.Record,
candidate.CreateIntegrationVersion); candidate.CreateIntegrationVersion));
} }
public void ResetSessionState() => _materializer.ResetSessionState(); 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( private bool ProjectExact(
LiveEntityRecord expectedRecord, LiveEntityRecord expectedRecord,
WorldSession.EntitySpawn acceptedSpawn, WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose, LiveProjectionPurpose purpose,
LiveEntityAppearanceUpdateState? appearanceUpdate = null,
ulong? expectedCreateIntegrationVersion = null) ulong? expectedCreateIntegrationVersion = null)
{ {
while (true) while (true)
@ -433,7 +646,6 @@ internal sealed class LiveEntityHydrationController
expectedRecord, expectedRecord,
acceptedSpawn, acceptedSpawn,
purpose, purpose,
appearanceUpdate,
createIntegrationVersion); createIntegrationVersion);
} }
finally finally
@ -449,7 +661,6 @@ internal sealed class LiveEntityHydrationController
// newest canonical snapshot; logical resources remain registered. // newest canonical snapshot; logical resources remain registered.
acceptedSpawn = expectedRecord.Snapshot; acceptedSpawn = expectedRecord.Snapshot;
purpose = LiveProjectionPurpose.CreateSupersessionRecovery; purpose = LiveProjectionPurpose.CreateSupersessionRecovery;
appearanceUpdate = null;
expectedCreateIntegrationVersion = expectedCreateIntegrationVersion =
expectedRecord.CreateIntegrationVersion; expectedRecord.CreateIntegrationVersion;
} }
@ -459,7 +670,6 @@ internal sealed class LiveEntityHydrationController
LiveEntityRecord expectedRecord, LiveEntityRecord expectedRecord,
WorldSession.EntitySpawn acceptedSpawn, WorldSession.EntitySpawn acceptedSpawn,
LiveProjectionPurpose purpose, LiveProjectionPurpose purpose,
LiveEntityAppearanceUpdateState? appearanceUpdate,
ulong createIntegrationVersion) ulong createIntegrationVersion)
{ {
_relationships.OnSpawn(acceptedSpawn); _relationships.OnSpawn(acceptedSpawn);
@ -550,8 +760,7 @@ internal sealed class LiveEntityHydrationController
expectedRecord, expectedRecord,
expectedRecord.Snapshot, expectedRecord.Snapshot,
purpose, purpose,
createIntegrationVersion, createIntegrationVersion);
appearanceUpdate);
if (!materialized if (!materialized
|| !_runtime.IsCurrentCreateIntegration( || !_runtime.IsCurrentCreateIntegration(
expectedRecord, expectedRecord,
@ -579,22 +788,46 @@ internal sealed class LiveEntityHydrationController
ulong expectedCreateIntegrationVersion) ulong expectedCreateIntegrationVersion)
{ {
if (!_runtime.IsCurrentCreateIntegration( if (!_runtime.IsCurrentCreateIntegration(
expectedRecord,
expectedCreateIntegrationVersion)
|| !_ready.Publish(expectedRecord)
|| !_runtime.IsCurrentCreateIntegration(
expectedRecord, expectedRecord,
expectedCreateIntegrationVersion)) expectedCreateIntegrationVersion))
{ {
return false; 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( return _runtime.TryMarkInitialHydrationCompleted(
expectedRecord, expectedRecord,
expectedCreateIntegrationVersion) candidate.CreateIntegrationVersion)
&& _runtime.IsCurrentCreateIntegration( && candidate.IsCurrent(_runtime);
}
private bool CompleteAppearanceProjectionSynchronization(
LiveEntityRecord expectedRecord,
ulong expectedObjDescAuthorityVersion)
{
if (!_runtime.TryCompleteAppearanceProjectionSynchronization(
expectedRecord, expectedRecord,
expectedCreateIntegrationVersion); expectedObjDescAuthorityVersion))
{
return false;
}
AppearanceApplied?.Invoke(expectedRecord.ServerGuid);
return _runtime.IsCurrentObjDescAuthority(
expectedRecord,
expectedObjDescAuthorityVersion);
} }
private void InitializeOriginAndRecoverLoaded( private void InitializeOriginAndRecoverLoaded(

View file

@ -1,16 +1,48 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.World; namespace AcDream.App.World;
internal sealed class DelegateLiveEntitySpawnRelationshipSink( internal sealed class LiveEntityRelationshipProjection(
Action<WorldSession.EntitySpawn> onSpawn) : ILiveEntitySpawnRelationshipSink EquippedChildRenderController children) : ILiveEntityRelationshipProjection
{ {
private readonly Action<WorldSession.EntitySpawn> _onSpawn = private readonly EquippedChildRenderController _children =
onSpawn ?? throw new ArgumentNullException(nameof(onSpawn)); 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);
}
/// <summary>
/// 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.
/// </summary>
internal sealed class DeferredLiveEntityParentAcceptance
{
private Func<ParentEvent.Parsed, bool>? _accept;
public void Bind(Func<ParentEvent.Parsed, bool> 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 internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
@ -49,31 +81,23 @@ internal sealed class LiveEntityReadyPublisher : ILiveEntityReadyPublisher
?? throw new ArgumentNullException(nameof(replayEffects)); ?? throw new ArgumentNullException(nameof(replayEffects));
} }
public bool Publish(LiveEntityRecord expectedRecord) public bool Publish(LiveEntityReadyCandidate candidate)
{ {
LiveEntityRecord expectedRecord = candidate.Record;
ArgumentNullException.ThrowIfNull(expectedRecord); ArgumentNullException.ThrowIfNull(expectedRecord);
ulong createIntegrationVersion = expectedRecord.CreateIntegrationVersion; if (!candidate.IsCurrent(_runtime)
if (!_runtime.IsCurrentCreateIntegration(
expectedRecord,
createIntegrationVersion)
|| !_prepareEffects(expectedRecord) || !_prepareEffects(expectedRecord)
|| !_runtime.IsCurrentCreateIntegration( || !candidate.IsCurrent(_runtime))
expectedRecord,
createIntegrationVersion))
return false; return false;
if (!_presentState(expectedRecord) if (!_presentState(expectedRecord)
|| !_runtime.IsCurrentCreateIntegration( || !candidate.IsCurrent(_runtime))
expectedRecord,
createIntegrationVersion))
{ {
return false; return false;
} }
return _replayEffects(expectedRecord) return _replayEffects(expectedRecord)
&& _runtime.IsCurrentCreateIntegration( && candidate.IsCurrent(_runtime);
expectedRecord,
createIntegrationVersion);
} }
} }

View file

@ -201,6 +201,7 @@ public sealed class LiveEntityRecord
? 0UL ? 0UL
: 1UL; : 1UL;
MovementAuthorityVersion = 1UL; MovementAuthorityVersion = 1UL;
ObjDescAuthorityVersion = 1UL;
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState); ApplyRawPhysicsState(RawPhysicsState);
} }
@ -258,6 +259,7 @@ public sealed class LiveEntityRecord
internal ulong VectorAuthorityVersion { get; private set; } internal ulong VectorAuthorityVersion { get; private set; }
internal ulong VelocityAuthorityVersion { get; private set; } internal ulong VelocityAuthorityVersion { get; private set; }
internal ulong MovementAuthorityVersion { get; private set; } internal ulong MovementAuthorityVersion { get; private set; }
internal ulong ObjDescAuthorityVersion { get; private set; }
/// <summary> /// <summary>
/// Advances for every accepted CreateObject affecting this incarnation, /// Advances for every accepted CreateObject affecting this incarnation,
/// including a fresher same-INSTANCE_TS retransmit. App integration uses /// including a fresher same-INSTANCE_TS retransmit. App integration uses
@ -284,6 +286,15 @@ public sealed class LiveEntityRecord
internal bool ProjectionHydrationInProgress { get; set; } internal bool ProjectionHydrationInProgress { get; set; }
internal ulong ProjectionHydrationCreateVersion { get; set; } internal ulong ProjectionHydrationCreateVersion { get; set; }
internal bool ProjectionHydrationRetryRequested { get; set; } internal bool ProjectionHydrationRetryRequested { get; set; }
/// <summary>
/// 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.
/// </summary>
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; } public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal bool RuntimeComponentsTeardownCompleted { get; set; } internal bool RuntimeComponentsTeardownCompleted { get; set; }
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
@ -338,6 +349,14 @@ public sealed class LiveEntityRecord
VelocityAuthorityVersion++; VelocityAuthorityVersion++;
} }
internal void AdvanceObjDescAuthority()
{
ObjDescAuthorityVersion++;
AppearanceProjectionSynchronizationPending = true;
if (AppearanceHydrationInProgress)
AppearanceHydrationRetryRequested = true;
}
internal void AdvanceCreateAuthority() internal void AdvanceCreateAuthority()
{ {
PositionAuthorityVersion++; PositionAuthorityVersion++;
@ -345,6 +364,7 @@ public sealed class LiveEntityRecord
VectorAuthorityVersion++; VectorAuthorityVersion++;
VelocityAuthorityVersion++; VelocityAuthorityVersion++;
MovementAuthorityVersion++; MovementAuthorityVersion++;
ObjDescAuthorityVersion++;
CreateIntegrationVersion++; CreateIntegrationVersion++;
} }
@ -1547,7 +1567,12 @@ public sealed class LiveEntityRuntime
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{ {
bool applied = _inbound.TryApplyObjDesc(update, out 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; return applied;
} }
@ -1805,6 +1830,13 @@ public sealed class LiveEntityRuntime
&& ReferenceEquals(current, record) && ReferenceEquals(current, record)
&& current.MovementAuthorityVersion == authorityVersion; && current.MovementAuthorityVersion == authorityVersion;
internal bool IsCurrentObjDescAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current)
&& ReferenceEquals(current, record)
&& current.ObjDescAuthorityVersion == authorityVersion;
/// <summary> /// <summary>
/// Copies the canonical loaded root-object workset used by retail /// Copies the canonical loaded root-object workset used by retail
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token; /// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
@ -2156,6 +2188,63 @@ public sealed class LiveEntityRuntime
return retry; 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( internal bool IsCurrentCreateIntegration(
LiveEntityRecord expectedRecord, LiveEntityRecord expectedRecord,
ulong expectedCreateIntegrationVersion) => ulong expectedCreateIntegrationVersion) =>
@ -2722,6 +2811,10 @@ public sealed class LiveEntityRuntime
record.ProjectionHydrationInProgress = false; record.ProjectionHydrationInProgress = false;
record.ProjectionHydrationCreateVersion = 0UL; record.ProjectionHydrationCreateVersion = 0UL;
record.ProjectionHydrationRetryRequested = false; record.ProjectionHydrationRetryRequested = false;
record.AppearanceProjectionSynchronizationPending = false;
record.AppearanceHydrationInProgress = false;
record.AppearanceHydrationVersion = 0UL;
record.AppearanceHydrationRetryRequested = false;
record.WorldEntity = null; record.WorldEntity = null;
} }
finally finally

View file

@ -433,6 +433,134 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); 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<Matrix4x4>());
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<Matrix4x4>());
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<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
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] [Fact]
public void ChildWithoutSetup_StillCommitsLogicalParenting() public void ChildWithoutSetup_StillCommitsLogicalParenting()
{ {

View file

@ -22,6 +22,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("RehydrateServerEntitiesForLandblock")] [InlineData("RehydrateServerEntitiesForLandblock")]
[InlineData("TryInitializeLiveCenter")] [InlineData("TryInitializeLiveCenter")]
[InlineData("CompleteLiveEntityReady")] [InlineData("CompleteLiveEntityReady")]
[InlineData("OnLiveAppearanceUpdated")]
[InlineData("OnLiveEntityPickedUp")]
[InlineData("OnLiveParentUpdated")]
[InlineData("TryAcceptParentForRender")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName) public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{ {
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation)); Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));

View file

@ -12,6 +12,25 @@ namespace AcDream.App.Tests.World;
public sealed class LiveEntityHydrationControllerTests 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => bridge.Bind(_ => false));
}
[Fact] [Fact]
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization() public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
{ {
@ -93,10 +112,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityRecord record = fixture.Record; LiveEntityRecord record = fixture.Record;
Assert.Null(record.WorldEntity); Assert.Null(record.WorldEntity);
Assert.True(fixture.Controller.ApplyAppearance( Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
record,
record.Snapshot,
appearanceUpdate: null));
Assert.NotNull(record.WorldEntity); Assert.NotNull(record.WorldEntity);
Assert.Equal(2, fixture.Materializer.Calls.Count); Assert.Equal(2, fixture.Materializer.Calls.Count);
@ -105,6 +121,324 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(1, fixture.Ready.PublishCount); 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<InvalidOperationException>(() =>
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] [Fact]
public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce() public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce()
{ {
@ -805,9 +1139,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityProjectionKind.Attached); LiveEntityProjectionKind.Attached);
Assert.Same(retained, attached); Assert.Same(retained, attached);
fixture.Controller.OnEntityReady( fixture.Controller.OnEntityReady(
new LiveEntityReadyCandidate( LiveEntityReadyCandidate.Capture(record));
record,
record.CreateIntegrationVersion));
}; };
bool refreshed = false; bool refreshed = false;
fixture.Materializer.BeforeMaterialize = (_, spawn) => fixture.Materializer.BeforeMaterialize = (_, spawn) =>
@ -880,9 +1212,8 @@ public sealed class LiveEntityHydrationControllerTests
}, },
LiveEntityProjectionKind.Attached); LiveEntityProjectionKind.Attached);
Assert.NotNull(attached); Assert.NotNull(attached);
fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate( fixture.Controller.OnEntityReady(
record, LiveEntityReadyCandidate.Capture(record));
record.CreateIntegrationVersion));
}; };
fixture.Controller.OnCreate(CelllessSpawn( fixture.Controller.OnCreate(CelllessSpawn(
@ -945,7 +1276,8 @@ public sealed class LiveEntityHydrationControllerTests
_ => { present++; return Stage(1); }, _ => { present++; return Stage(1); },
_ => { replay++; return Stage(2); }); _ => { replay++; return Stage(2); });
Assert.False(publisher.Publish(expected)); Assert.False(publisher.Publish(
LiveEntityReadyCandidate.Capture(expected)));
Assert.Equal(expectedPrepare, prepare); Assert.Equal(expectedPrepare, prepare);
Assert.Equal(expectedPresent, present); Assert.Equal(expectedPresent, present);
Assert.Equal(expectedReplay, replay); Assert.Equal(expectedReplay, replay);
@ -953,6 +1285,52 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion); 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] [Fact]
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback() public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
{ {
@ -970,9 +1348,35 @@ public sealed class LiveEntityHydrationControllerTests
Assert.False(EquippedChildRenderController.PublishEntityReadyExact( Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
fixture.Runtime, fixture.Runtime,
record, LiveEntityReadyCandidate.Capture(record) with
capturedCreateIntegrationVersion, {
entity, 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)); _ => published = true));
Assert.False(published); Assert.False(published);
} }
@ -1256,14 +1660,46 @@ public sealed class LiveEntityHydrationControllerTests
} }
private sealed class RecordingRelationships(List<string> operations) private sealed class RecordingRelationships(List<string> operations)
: ILiveEntitySpawnRelationshipSink : ILiveEntityRelationshipProjection
{ {
public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; } public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; }
public Action<ParentEvent.Parsed>? OnParentAction { get; set; }
public Action<CreateParentUpdate>? OnCreateParentAction { get; set; }
public Func<uint, ChildUnparentDisposition>? OnUnparentAction { get; set; }
public Func<LiveEntityRecord, ulong, bool>? ApplyAttachedAppearance { get; set; }
public void OnSpawn(WorldSession.EntitySpawn spawn) public void OnSpawn(WorldSession.EntitySpawn spawn)
{ {
operations.Add($"relationship:{spawn.InstanceSequence}"); operations.Add($"relationship:{spawn.InstanceSequence}");
OnSpawnAction?.Invoke(spawn); 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<string> operations) private sealed class RecordingReadyPublisher(List<string> operations)
@ -1272,8 +1708,9 @@ public sealed class LiveEntityHydrationControllerTests
public int PublishCount { get; private set; } public int PublishCount { get; private set; }
public int FailPublishCount { get; set; } public int FailPublishCount { get; set; }
public Action<LiveEntityRecord>? BeforePublish { get; set; } public Action<LiveEntityRecord>? BeforePublish { get; set; }
public bool Publish(LiveEntityRecord expectedRecord) public bool Publish(LiveEntityReadyCandidate candidate)
{ {
LiveEntityRecord expectedRecord = candidate.Record;
PublishCount++; PublishCount++;
operations.Add("ready"); operations.Add("ready");
BeforePublish?.Invoke(expectedRecord); BeforePublish?.Invoke(expectedRecord);
@ -1357,6 +1794,19 @@ public sealed class LiveEntityHydrationControllerTests
Physics: physics); Physics: physics);
} }
private static ObjDescEvent.Parsed ObjDesc(
ushort sequence,
uint basePaletteId) =>
new(
Guid,
new CreateObject.ModelData(
basePaletteId,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
InstanceSequence: 1,
ObjDescSequence: sequence);
private static WorldSession.EntitySpawn CelllessSpawn( private static WorldSession.EntitySpawn CelllessSpawn(
ushort PositionSequence, ushort PositionSequence,
uint? parentGuid) uint? parentGuid)