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

@ -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
}
}
/// <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>
/// 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<LiveEntityReadyCandidate>? 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(

View file

@ -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.
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// <see cref="LiveEntityHydrationController.OnAppearance"/> reuses the cached
/// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
/// updated visuals.
/// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> 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<DatReaderWriter.DBObjs.Setup>(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));
}
/// <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>
/// 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);
}
/// <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(
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)