refactor(world): separate live lifetime from spatial buckets

Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners.

Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects.

Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 07:47:03 +02:00
parent 8a5d77f7f4
commit 8dd996053d
24 changed files with 2449 additions and 631 deletions

View file

@ -36,7 +36,7 @@ public static class DollEntityBuilder
/// <summary>
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
/// and high enough never to collide with <c>_liveEntityIdCounter</c> ids. The
/// and high enough never to collide with LiveEntityRuntime's local ids. The
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
/// doll as animated — which BYPASSES the Tier-1 classification cache
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW

View file

@ -1,5 +1,5 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
@ -24,13 +24,10 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly DatCollection _dats;
private readonly object _datLock;
private readonly ClientObjectTable _objects;
private readonly GpuWorldState _worldState;
private readonly Func<uint, WorldEntity?> _resolveEntity;
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
private readonly LiveEntityRuntime _liveEntities;
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private readonly Func<uint> _nextEntityId;
private readonly ParentAttachmentState _relations = new();
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
public IEnumerable<uint> AttachedEntityIds
@ -46,20 +43,14 @@ public sealed class EquippedChildRenderController : IDisposable
DatCollection dats,
object datLock,
ClientObjectTable objects,
GpuWorldState worldState,
Func<uint, WorldEntity?> resolveEntity,
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
Func<ParentEvent.Parsed, bool> acceptParent,
Func<uint> nextEntityId)
LiveEntityRuntime liveEntities,
Func<ParentEvent.Parsed, bool> acceptParent)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack;
@ -79,7 +70,7 @@ public sealed class EquippedChildRenderController : IDisposable
&& spawn.ParentLocation is { } parentLocation
&& spawn.PlacementId is { } placementId)
{
_relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid,
spawn.Guid,
parentLocation,
@ -93,7 +84,7 @@ public sealed class EquippedChildRenderController : IDisposable
// ParentEvent can precede the parent's CreateObject. Revisit every
// child waiting specifically on the object that just arrived.
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(spawn.Guid);
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(spawn.Guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
@ -112,7 +103,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(guid);
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
@ -130,7 +121,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
_relations.Enqueue(update);
Relations.Enqueue(update);
ResolveRelations(update.ChildGuid);
TryRealize(update.ChildGuid);
}
@ -139,13 +130,11 @@ public sealed class EquippedChildRenderController : IDisposable
public void OnGenerationReplaced(uint guid, ushort replacementGeneration)
{
TearDownObjectProjections(guid);
_relations.EndGeneration(guid, replacementGeneration);
}
public void OnGenerationDeleted(uint guid, ushort deletedGeneration)
{
TearDownObjectProjections(guid);
_relations.DeleteGeneration(guid, deletedGeneration);
}
private void OnObjectRemovalClassified(ClientObjectRemoval removal)
@ -160,7 +149,7 @@ public sealed class EquippedChildRenderController : IDisposable
// same-generation world/parent update can still replay them. Logical
// delete/replacement are driven directly by the accepted inbound
// lifecycle and never depend on this table.
_relations.EndChildProjection(guid);
Relations.EndChildProjection(guid);
}
/// <summary>
@ -172,7 +161,7 @@ public sealed class EquippedChildRenderController : IDisposable
public void OnChildBecameUnparented(uint childGuid)
{
Remove(childGuid);
_relations.EndChildProjection(childGuid);
Relations.EndChildProjection(childGuid);
}
/// <summary>Recompose every child after the parent's animation tick.</summary>
@ -180,8 +169,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
foreach (AttachedChild child in _attachedByChild.Values)
{
WorldEntity? parent = _resolveEntity(child.ParentGuid);
if (parent is null)
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
continue;
if (!EquippedChildAttachment.TryCompose(
@ -199,22 +187,23 @@ public sealed class EquippedChildRenderController : IDisposable
child.Entity.SetPosition(parent.Position);
child.Entity.Rotation = parent.Rotation;
child.Entity.ParentCellId = parent.ParentCellId;
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
}
}
private void TryRealize(uint childGuid)
{
if (!_relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
return;
WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid);
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid);
WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid);
if (parentEntity is null || parentSpawn is null || childSpawn is null)
if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
return;
if (parentSpawn.Value.SetupTableId is not { } parentSetupId
|| childSpawn.Value.SetupTableId is not { } childSetupId
if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId)
return;
@ -225,8 +214,8 @@ public sealed class EquippedChildRenderController : IDisposable
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn.Value);
float scale = childSpawn.Value.ObjScale is { } objScale && objScale > 0f
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn);
float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale
: 1.0f;
@ -242,19 +231,32 @@ public sealed class EquippedChildRenderController : IDisposable
return;
Remove(childGuid);
var entity = new WorldEntity
{
Id = _nextEntityId(),
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position,
Rotation = parentEntity.Rotation,
MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn.Value),
ParentCellId = parentCellId,
};
_worldState.AppendLiveEntity(parentCellId, entity);
WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
parentCellId,
localId => new WorldEntity
{
Id = localId,
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position,
Rotation = parentEntity.Rotation,
MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
},
LiveEntityProjectionKind.Attached);
if (entity is null)
return;
entity.SetPosition(parentEntity.Position);
entity.Rotation = parentEntity.Rotation;
entity.ParentCellId = parentCellId;
entity.ApplyAppearance(
parts,
BuildPaletteOverride(childSpawn),
childSpawn.AnimPartChanges is { Count: > 0 } changes
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty<PartOverride>());
_attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid,
childGuid,
@ -268,15 +270,17 @@ public sealed class EquippedChildRenderController : IDisposable
Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
_relations.MarkProjected(childGuid);
Relations.MarkProjected(childGuid);
}
private void ResolveRelations(uint childGuid)
{
_relations.Resolve(
Relations.Resolve(
childGuid,
guid => _resolveSpawn(guid) is not null,
guid => _resolveSpawn(guid)?.InstanceSequence,
guid => _liveEntities.TryGetSnapshot(guid, out _),
guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null,
_acceptParent);
}
@ -370,7 +374,7 @@ public sealed class EquippedChildRenderController : IDisposable
// A rejected unwield restores the equipped location without a fresh
// wire ParentEvent; reinstall the last accepted relationship only for
// this explicit rollback signal.
if (_relations.RestoreLastAccepted(item.ObjectId))
if (Relations.RestoreLastAccepted(item.ObjectId))
{
lock (_datLock)
TryRealize(item.ObjectId);
@ -381,7 +385,7 @@ public sealed class EquippedChildRenderController : IDisposable
{
if (!_attachedByChild.Remove(childGuid))
return;
_worldState.RemoveEntityByServerGuid(childGuid);
_liveEntities.WithdrawLiveEntityProjection(childGuid);
}
private void TearDownObjectProjections(uint guid)
@ -401,7 +405,7 @@ public sealed class EquippedChildRenderController : IDisposable
uint[] attached = _attachedByChild.Keys.ToArray();
for (int i = 0; i < attached.Length; i++)
Remove(attached[i]);
_relations.Clear();
Relations.Clear();
}
public void Dispose()

View file

@ -1,4 +1,5 @@
using AcDream.Core.Plugins;
using AcDream.App.World;
using DatReaderWriter;
using DatReaderWriter.Options;
using Silk.NET.Input;
@ -245,7 +246,7 @@ public sealed class GameWindow : IDisposable
/// Static decorations and entities with no motion table never
/// appear in this map.
/// </summary>
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
private readonly LiveEntityAnimationRuntimeView<AnimatedEntity> _animatedEntities;
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
@ -268,9 +269,10 @@ public sealed class GameWindow : IDisposable
// #184 Slice 2a: internal (was private) so the extracted
// RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches
// RemoteMotion's existing internal visibility.
internal sealed class AnimatedEntity
internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime
{
public required AcDream.Core.World.WorldEntity Entity;
AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity;
public required DatReaderWriter.DBObjs.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame;
@ -412,7 +414,7 @@ public sealed class GameWindow : IDisposable
/// motion tables with HasVelocity=0).
/// </para>
/// </summary>
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
/// <summary>
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
@ -422,7 +424,7 @@ public sealed class GameWindow : IDisposable
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
/// <see cref="OnLiveEntityDeleted"/>.
/// </summary>
private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new();
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
@ -440,9 +442,10 @@ public sealed class GameWindow : IDisposable
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
internal sealed class RemoteMotion : AcDream.App.World.ILiveEntityRemoteMotionRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
{
public AcDream.Core.Physics.PhysicsBody Body;
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
/// ONE per-entity owner of the interp + moveto pair (acclient.h
@ -983,7 +986,6 @@ public sealed class GameWindow : IDisposable
// closes (stale-centered geometry built during the InWorld-but-not-yet-
// located window, applied late once its build finished).
private bool _liveCenterKnown;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
// single-flag reads instead of env-var lookups. True iff
@ -1016,10 +1018,19 @@ public sealed class GameWindow : IDisposable
/// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. The sequential <see cref="_liveEntityIdCounter"/>
/// keys the render list; this parallel dictionary keys by server guid.
/// server is talking about. This is the canonical materialized top-level
/// projection, including live objects parked in pending landblocks;
/// visibility must never suppress authoritative mutation.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> EmptyLiveEntityMap =
new Dictionary<uint, AcDream.Core.World.WorldEntity>();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> EmptyLiveSpawnMap =
new Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn>();
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid =>
_liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap;
/// <summary>Visible-only view for radar, picking, status, and targets.</summary>
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities?.WorldEntities ?? EmptyLiveEntityMap;
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured before the renderability gate so no-position inventory /
@ -1029,7 +1040,7 @@ public sealed class GameWindow : IDisposable
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_inboundPhysics.Snapshots;
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
// once the local auto-walk overlay reports arrival (body has finished
// rotating to face the target). Only set for close-range Use/PickUp;
@ -1047,7 +1058,7 @@ public sealed class GameWindow : IDisposable
// CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
// giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
// (player); pruned in RemoveLiveEntityByServerGuid.
// (player); pruned only by logical LiveEntityRuntime teardown.
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
@ -1078,6 +1089,8 @@ public sealed class GameWindow : IDisposable
_worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<AnimatedEntity>(() => _liveEntities);
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _liveEntities);
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Shares GameWindow's
@ -1511,7 +1524,7 @@ public sealed class GameWindow : IDisposable
// around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame:
// - selected guid → shared SelectionState
// - entity resolver → position from _entitiesByServerGuid +
// - entity resolver → position from the visible world view +
// itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0.
@ -1519,7 +1532,7 @@ public sealed class GameWindow : IDisposable
selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid =>
{
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity))
return null;
uint rawItemType = (uint)LiveItemType(guid);
uint pwdBits = 0;
@ -2098,7 +2111,7 @@ public sealed class GameWindow : IDisposable
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
_visibleEntitiesByServerGuid,
LastSpawns,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
@ -2304,21 +2317,36 @@ public sealed class GameWindow : IDisposable
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.DelegateLiveEntityResourceLifecycle(
entity =>
{
wbEntitySpawnAdapter.OnCreate(entity);
entityScriptActivator.OnCreate(entity);
},
entity =>
{
try
{
entityScriptActivator.OnRemove(entity.Id);
}
finally
{
wbEntitySpawnAdapter.OnRemove(entity.ServerGuid);
}
}),
TearDownLiveEntityRuntimeComponents);
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_worldState,
guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null,
guid => LastSpawns.TryGetValue(guid, out var spawn)
? spawn
: (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
TryAcceptParentForRender,
() => _liveEntityIdCounter++);
_liveEntities,
TryAcceptParentForRender);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
@ -2537,7 +2565,16 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer?.Clear();
Objects.Clear();
_selection.Reset();
_inboundPhysics.Clear();
try
{
_liveEntities?.Clear();
}
finally
{
// F754 can precede CreateObject, so some temporary owners have no
// LiveEntityRecord for the normal logical teardown to visit.
_entityScriptActivator?.ClearLegacyPendingOwners();
}
}
/// <summary>
@ -2933,32 +2970,49 @@ public sealed class GameWindow : IDisposable
// with BuildLandblockForStreaming on the worker thread.
lock (_datLock)
{
AcDream.App.World.InboundCreateResult result = _inboundPhysics.AcceptCreate(spawn);
AcDream.App.World.LiveEntityRegistrationResult registration =
_liveEntities!.RegisterLiveEntity(spawn);
AcDream.App.World.InboundCreateResult result = registration.Inbound;
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
return;
PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration)
try
{
_equippedChildRenderer?.OnGenerationReplaced(
spawn.Guid,
result.Snapshot.InstanceSequence);
PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration)
{
_equippedChildRenderer?.OnGenerationReplaced(
spawn.Guid,
result.Snapshot.InstanceSequence);
}
AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn(
Objects,
spawn,
replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration)
{
if (result.SameGenerationEvents is { } refresh)
RouteSameGenerationCreateObject(refresh);
return;
}
OnLiveEntitySpawnedLocked(result.Snapshot);
}
catch (Exception applyFailure) when (registration.PriorGenerationCleanupFailure is not null)
{
throw new AggregateException(
$"Live entity 0x{spawn.Guid:X8} replacement cleanup and installation both failed.",
registration.PriorGenerationCleanupFailure,
applyFailure);
}
AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn(
Objects,
spawn,
replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration);
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration)
{
if (result.SameGenerationEvents is { } refresh)
RouteSameGenerationCreateObject(refresh);
return;
}
OnLiveEntitySpawnedLocked(result.Snapshot);
if (registration.PriorGenerationCleanupFailure is { } cleanupFailure)
throw new AggregateException(
$"Prior incarnation of live entity 0x{spawn.Guid:X8} failed teardown after its replacement was installed.",
cleanupFailure);
}
}
@ -2969,8 +3023,8 @@ public sealed class GameWindow : IDisposable
/// <c>AddLandblock</c> / <c>AddEntitiesToExistingLandblock</c>.
///
/// <para>
/// The dungeon collapse (and Near→Far demote) drops a landblock's render
/// entities for FPS but keeps the parsed spawns in <see cref="LastSpawns"/>
/// A full landblock unload drops its dat-static render layer and parks live
/// projections pending, while keeping the parsed spawns in <see cref="LastSpawns"/>
/// (our <c>weenie_object_table</c> for world objects). ACE never
/// re-broadcasts objects it believes we still know — its per-player
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
@ -2981,55 +3035,53 @@ public sealed class GameWindow : IDisposable
/// </para>
///
/// <para>
/// Idempotent and cheap when nothing is missing: the selection skips guids
/// already present in <see cref="_worldState"/> (initial login, or objects
/// ACE did re-send), the player (owned by the persistent-entity rescue
/// path), and spawns with no world mesh. The replay's own
/// <see cref="RemoveLiveEntityByServerGuid"/> de-dup scrubs the state the
/// collapse orphaned — the entity lingers in <see cref="_entitiesByServerGuid"/>
/// after <c>RemoveLandblock</c> even though its render entity is gone.
/// Idempotent and cheap when nothing is missing. A materialized record is
/// rebucketed with the same identity; a record whose Setup was unavailable
/// on first arrival gets one first-materialization attempt. Neither path
/// replays logical renderer or script registration.
/// </para>
/// </summary>
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId)
{
if (LastSpawns.Count == 0) return;
if (_liveEntities is null || _liveEntities.Count == 0) return;
// Server guids that already have a live render entity. The gate keys on
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which
// still holds collapse-orphaned entries whose render entity is gone.
var present = new HashSet<uint>();
foreach (var e in _worldState.Entities)
if (e.ServerGuid != 0) present.Add(e.ServerGuid);
uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
LiveEntityRecord[] records = _liveEntities.Records
.Where(record => record.ServerGuid != _playerServerGuid
&& record.Snapshot.Position is { } position
&& ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical
&& record.Snapshot.SetupTableId is not null)
.ToArray();
if (records.Length == 0) return;
// Snapshot retained spawns before render projection changes.
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
LastSpawns.Count);
foreach (var kv in LastSpawns)
{
var sp = kv.Value;
bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null;
uint spawnLb = sp.Position is { } p ? p.LandblockId : 0u;
retained.Add(new AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn(
kv.Key, spawnLb, hasWorldMesh));
}
var guids = AcDream.App.Streaming.LandblockEntityRehydrator.SelectGuidsToRehydrate(
loadedLandblockId, retained, present, _playerServerGuid);
if (guids.Count == 0) return;
// Replay through the normal live-spawn build under the dat lock (it reads
// Setup/GfxObj/Surface dats). Each replay rebuilds the render entity into
// the now-loaded landblock via AppendLiveEntity's hot path.
int projected = 0;
lock (_datLock)
{
foreach (var guid in guids)
if (LastSpawns.TryGetValue(guid, out var spawn))
OnLiveEntitySpawnedLocked(spawn);
foreach (LiveEntityRecord record in records)
{
// A materialized object keeps the same WorldEntity, renderer
// registration, animation owner, and scripts. Reloading a
// landblock changes only its spatial bucket. Hydration is used
// solely for a record that never acquired a projection.
if (record.WorldEntity is not null)
{
if (_liveEntities.RebucketLiveEntity(
record.ServerGuid,
record.Snapshot.Position!.Value.LandblockId))
projected++;
}
else
{
OnLiveEntitySpawnedLocked(record.Snapshot);
if (record.WorldEntity is not null)
projected++;
}
}
}
if (_options.DumpLiveSpawns)
if (_options.DumpLiveSpawns && projected > 0)
Console.WriteLine(
$"live: re-hydrated {guids.Count} server object(s) into landblock 0x{loadedLandblockId:X8}");
$"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}");
}
/// <summary>
@ -3051,19 +3103,10 @@ public sealed class GameWindow : IDisposable
{
_liveSpawnReceived++;
// De-dup: the server re-sends CreateObject for the same guid in
// several situations (visibility refresh, landblock crossing,
// appearance update). Without cleanup the OLD copy remains in
// GpuWorldState + WorldGameState + _animatedEntities, so the
// renderer draws both copies overlapped — producing the
// "NPC clothing changes when I turn the camera" bug because the
// depth test arbitrates between overlapping duplicates each frame.
//
// For a respawn, drop the previous rendering state here before we
// build the new one. `_entitiesByServerGuid` is the canonical map,
// its value is the live WorldEntity we need to dispose.
if (appearanceUpdate is null)
RemoveLiveEntityByServerGuid(spawn.Guid);
// LiveEntityRuntime has already classified this as a first
// materialization, a spatial re-entry of the same incarnation, or an
// appearance mutation. This method never de-duplicates by destroying a
// still-live projection and never reconstructs from stale spawn data.
// Retail's weenie-object table retains CreateObject data for inventory
// and parented children even when they have no world Position. Held
@ -3076,7 +3119,7 @@ public sealed class GameWindow : IDisposable
// it above can synchronously advance the canonical child POSITION_TS
// and turn this object into an attachment. Select the projection from
// that post-callback snapshot, never from the stale method argument.
if (_inboundPhysics.TryGetSnapshot(spawn.Guid, out var canonicalSpawn))
if (_liveEntities!.TryGetSnapshot(spawn.Guid, out var canonicalSpawn))
spawn = canonicalSpawn;
// When requested, log every spawn that arrives so we can inventory what the server
@ -3634,23 +3677,61 @@ public sealed class GameWindow : IDisposable
if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
_classificationCache.InvalidateEntity(existing.Id);
if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
&& record.ProjectionKind is LiveEntityProjectionKind.Attached)
{
// The attachment controller composes child-local parts through
// the parent's current animated pose. Re-run that composition
// after replacing the child's visual description.
_equippedChildRenderer?.OnSpawn(spawn);
}
return;
}
var entity = new AcDream.Core.World.WorldEntity
bool createdProjection = false;
var entity = _liveEntities!.MaterializeLiveEntity(
spawn.Guid,
spawn.Position!.Value.LandblockId,
localId =>
{
createdProjection = true;
var created = new AcDream.Core.World.WorldEntity
{
Id = localId,
ServerGuid = spawn.Guid,
SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
Position = worldPos,
Rotation = rot,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position.Value.LandblockId,
};
if (liveBounds.TryGet(out var createdMin, out var createdMax))
created.SetLocalBounds(createdMin, createdMax);
return created;
});
if (entity is null)
return;
if (!createdProjection)
{
Id = _liveEntityIdCounter++,
ServerGuid = spawn.Guid,
SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
Position = worldPos,
Rotation = rot,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position!.Value.LandblockId,
};
if (liveBounds.TryGet(out var liveBMin, out var liveBMax))
entity.SetLocalBounds(liveBMin, liveBMax);
// A parented child already owns this incarnation's WorldEntity.
// Reuse it when a fresh Position returns the object to the world.
entity.SetPosition(worldPos);
entity.Rotation = rot;
entity.ParentCellId = spawn.Position.Value.LandblockId;
entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
if (liveBounds.TryGet(out var retainedMin, out var retainedMax))
entity.SetLocalBounds(retainedMin, retainedMax);
}
// Retail CPhysicsObj::leave_world removes cell/shadow membership but
// retains PartArray and MovementManager. A Position after Pickup or
// parenting therefore re-enters with the same animation owner; do not
// replace its sequencer or replay initialization.
bool retainedAnimationRuntime = !createdProjection
&& _animatedEntities.TryGetValue(entity.Id, out _);
// A7 indoor lighting: server-spawned weenie fixtures (lanterns,
// braziers, glowing items) carry their light in Setup.Lights exactly
@ -3658,8 +3739,8 @@ public sealed class GameWindow : IDisposable
// ApplyLoadedTerrainLocked never sees CreateObject entities — so an
// interior's lanterns cast no light and the room reads dark. Register
// them here, mirroring that path (GameWindow.cs ~6742). Owned by
// entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears
// them down on despawn/respawn. Retail registers object-borne lights
// entity.Id, so leave-world and logical teardown both remove their
// cell-scoped presentation. Retail registers object-borne lights
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
// The light is placed at the spawn frame and does NOT follow a moving
// light-bearer yet (register row AP-44) — fine for stationary fixtures,
@ -3699,17 +3780,18 @@ public sealed class GameWindow : IDisposable
Position: entity.Position,
Rotation: entity.Rotation);
_worldGameState.Add(snapshot);
_worldEvents.FireEntitySpawned(snapshot);
if (_liveEntities!.TryMarkWorldSpawnPublished(spawn.Guid))
_worldEvents.FireEntitySpawned(snapshot);
// Phase A.1: register entity into GpuWorldState so the next frame picks
// it up. AppendLiveEntity is a no-op if the landblock isn't loaded yet
// (can happen if the server sends CreateObjects before we finish loading).
_worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity);
// it up. Materialization parks the projection when its landblock is
// not loaded yet, then AddLandblock merges the same identity.
// MaterializeLiveEntity above performed the spatial projection.
_liveSpawnHydrated++;
// Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
_entitiesByServerGuid[spawn.Guid] = entity;
// The GUID/local-id mapping is owned by LiveEntityRuntime.
// The root now exists, so parent relations that arrived before this
// object's render projection can compose their child meshes.
@ -3738,16 +3820,20 @@ public sealed class GameWindow : IDisposable
// cycle (single-frame poses are static and don't need ticking).
// Diagnostic: log why we did / didn't register so we can tell
// which entities fall through the filter.
if (idleCycle is null)
_liveAnimRejectNoCycle++;
else if (idleCycle.Framerate == 0f)
_liveAnimRejectFramerate++;
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
_liveAnimRejectSingleFrame++;
else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++;
if (!retainedAnimationRuntime)
{
if (idleCycle is null)
_liveAnimRejectNoCycle++;
else if (idleCycle.Framerate == 0f)
_liveAnimRejectFramerate++;
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
_liveAnimRejectSingleFrame++;
else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++;
}
if (idleCycle is not null && idleCycle.Framerate != 0f
if (!retainedAnimationRuntime
&& idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1)
{
@ -3799,7 +3885,7 @@ public sealed class GameWindow : IDisposable
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
else if (_animLoader is not null)
else if (!retainedAnimationRuntime && _animLoader is not null)
{
// Phase B.4c / #187 — reactive motion-table rescue. An entity
// whose REST pose is a static single frame fails the generic
@ -3908,17 +3994,23 @@ public sealed class GameWindow : IDisposable
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{
if (!_inboundPhysics.TryDelete(
delete,
isLocalPlayer: delete.Guid == _playerServerGuid))
return;
_equippedChildRenderer?.OnGenerationDeleted(
delete.Guid,
delete.InstanceSequence);
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
// A Delete can arrive before CreateObject, in which case no instance
// timestamp owner exists and the tracked F754 alias must be cleaned
// directly. For a known record, cleanup belongs after the runtime's
// generation gate: a stale Delete must not cancel the current
// incarnation's effect.
if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
_entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u);
bool removed = _liveEntities!.UnregisterLiveEntity(
delete,
isLocalPlayer: delete.Guid == _playerServerGuid,
beforeTeardown: () =>
{
_equippedChildRenderer?.OnGenerationDeleted(
delete.Guid,
delete.InstanceSequence);
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
});
if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
@ -3941,7 +4033,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
{
if (!_inboundPhysics.TryApplyObjDesc(update, out var newSpawn))
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
@ -3971,7 +4063,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private AppearanceUpdateState? CaptureLiveAppearanceState(uint serverGuid)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var entity))
if (_liveEntities?.TryGetWorldEntity(serverGuid, out var entity) != true)
return null;
_animatedEntities.TryGetValue(entity.Id, out var animation);
@ -4107,7 +4199,7 @@ public sealed class GameWindow : IDisposable
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
/// One entry per entity (in contrast to static scenery's per-CylSphere
/// registration) so <c>RemoveLiveEntityByServerGuid</c>'s single
/// registration) so the leave-world path's single
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
///
/// <para>
@ -4272,9 +4364,9 @@ public sealed class GameWindow : IDisposable
OnLiveAppearanceUpdated(refresh.Appearance);
if (refresh.Parent is { } parent
&& _inboundPhysics.TryApplyCreateParent(parent, out var acceptedParent))
&& _liveEntities!.TryApplyCreateParent(parent, out var acceptedParent))
{
RemoveLiveEntityByServerGuid(parent.ChildGuid);
WithdrawLiveEntityWorldProjection(parent.ChildGuid);
_equippedChildRenderer?.OnSpawn(acceptedParent);
}
else if (refresh.Position is { } position)
@ -4299,11 +4391,11 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
{
if (!_inboundPhysics.TryApplyPickup(pickup, out _))
if (!_liveEntities!.TryApplyPickup(pickup, out _))
return;
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid);
bool removed = RemoveLiveEntityByServerGuid(pickup.Guid);
bool removed = WithdrawLiveEntityWorldProjection(pickup.Guid);
if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
@ -4325,8 +4417,8 @@ public sealed class GameWindow : IDisposable
private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
{
if (!_inboundPhysics.TryApplyParent(update, out _)) return false;
RemoveLiveEntityByServerGuid(update.ChildGuid);
if (!_liveEntities!.TryApplyParent(update, out _)) return false;
WithdrawLiveEntityWorldProjection(update.ChildGuid);
return true;
}
@ -4671,13 +4763,22 @@ public sealed class GameWindow : IDisposable
host.PositionManager.StickTo(targetGuid, radius, height);
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
return false;
// AD-32 transitional F754 ownership: before Step 4's mixed pending
// packet FIFO, a direct script received before materialization is
// temporarily keyed by server GUID. It is still part of this logical
// incarnation and must be stopped even when no WorldEntity was ever
// materialized. Normal Setup/F754 owners use the local ID and are
// stopped by the resource lifecycle after this callback.
_entityScriptActivator?.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u);
_worldState.RemoveEntityByServerGuid(serverGuid);
_worldGameState.RemoveById(existingEntity.Id);
if (record.WorldEntity is not { } existingEntity)
return;
uint serverGuid = record.ServerGuid;
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
// independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops
@ -4713,15 +4814,11 @@ public sealed class GameWindow : IDisposable
_physicsHosts.Remove(serverGuid);
_animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
if (serverGuid == _playerServerGuid)
_lastLocalPlayerShadow = null;
// Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the next spawn/update would use.
_remoteDeadReckon.Remove(serverGuid);
_remoteLastMove.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
@ -4729,15 +4826,47 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
}
// A7 indoor lighting: release this entity's owned lights on EVERY
// removal, including the respawn-dedup path (former logDelete=false).
// A respawned weenie fixture would otherwise leak its old light set and
// double-register the new one. (Was gated on logDelete — harmless only
// while live weenies registered no lights, which is no longer true.)
_lightingSink?.UnregisterOwner(existingEntity.Id);
_translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record);
}
return true;
/// <summary>
/// Retail <c>CPhysicsObj::leave_world</c> (0x005155A0): remove cell/shadow
/// presentation while retaining the PartArray, MovementManager, script
/// manager, animation owner, physics host, and effect owner. Pickup and
/// ParentEvent use this weaker transition; logical delete additionally
/// executes <see cref="TearDownLiveEntityRuntimeComponents"/>.
/// </summary>
private void LeaveWorldLiveEntityRuntimeComponents(LiveEntityRecord record)
{
if (record.WorldEntity is not { } entity)
return;
_worldGameState.RemoveById(entity.Id);
_physicsEngine.ShadowObjects.Deregister(entity.Id);
if (record.ServerGuid == _playerServerGuid)
_lastLocalPlayerShadow = null;
// Object-borne lights are cell-scoped presentation. The owning Setup
// and logical light capability remain on the live record; re-entry
// registers the light in the new cell without duplicating it.
_lightingSink?.UnregisterOwner(entity.Id);
}
/// <summary>
/// Removes the world-facing projection of a still-live object. Pickup and
/// parenting advance POSITION_TS but do not end the object incarnation, so
/// the runtime identity and create-time mesh/script resources survive.
/// </summary>
private bool WithdrawLiveEntityWorldProjection(uint serverGuid)
{
if (_liveEntities is null
|| !_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null)
return false;
LeaveWorldLiveEntityRuntimeComponents(record);
return _liveEntities.WithdrawLiveEntityProjection(serverGuid);
}
/// <summary>
@ -4884,7 +5013,7 @@ public sealed class GameWindow : IDisposable
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
// straggler re-applies an old gait or un-stops a stop.
bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
if (!_inboundPhysics.TryApplyMotion(
if (!_liveEntities!.TryApplyMotion(
update,
retainPayload,
out _,
@ -5374,7 +5503,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
{
if (!_inboundPhysics.TryApplyVector(update, out _)) return;
if (!_liveEntities!.TryApplyVector(update, out _)) return;
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
@ -5444,7 +5573,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
{
if (!_inboundPhysics.TryApplyState(parsed, out _)) return;
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
@ -5576,7 +5705,7 @@ public sealed class GameWindow : IDisposable
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
bool known = _inboundPhysics.TryApplyPosition(
bool known = _liveEntities!.TryApplyPosition(
update,
isLocalPlayer: update.Guid == _playerServerGuid,
forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null
@ -5674,6 +5803,7 @@ public sealed class GameWindow : IDisposable
entity.SetPosition(worldPos);
entity.ParentCellId = p.LandblockId;
entity.Rotation = rot;
_liveEntities!.RebucketLiveEntity(update.Guid, p.LandblockId);
// Commit B 2026-04-29 — keep the shadow registry in sync with
// server-authoritative position so the player's collision broadphase
@ -6402,7 +6532,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (!_inboundPhysics.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
return;
if (_playerController is not null)
@ -6417,19 +6547,15 @@ public sealed class GameWindow : IDisposable
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// with the CAMERA's current world position as the anchor. For
/// scene-wide storm effects (lightning) the camera is the right
/// reference frame since the flash is meant to be "around the
/// player." For per-entity effects the runner's dedupe by
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays
/// working on different guids.
/// Known server GUIDs are translated to the canonical local entity ID so
/// logical teardown stops both Setup and network-triggered scripts through
/// one owner key. Their current entity position is the anchor. Unknown
/// owners retain the legacy camera anchor until Step 4's pending FIFO can
/// replay them after materialization.
///
/// <para>
/// Improvements for follow-up: look up the guid's actual last-
/// known world position from <c>_worldState</c> so per-entity
/// spell casts and emote gestures anchor correctly. For Phase 6
/// scope (lightning, which is Dereth-wide) the camera anchor is
/// sufficient.
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
/// typed PhysicsScriptTable.
/// </para>
/// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
@ -6443,7 +6569,18 @@ public sealed class GameWindow : IDisposable
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
_scriptRunner.Play(message.ScriptDid, message.Guid, camWorldPos);
System.Numerics.Vector3 anchor = camWorldPos;
if (_liveEntities?.TryGetWorldEntity(message.Guid, out var entity) == true)
{
anchor = entity.Position;
_scriptRunner.Play(message.ScriptDid, entity.Id, anchor);
return;
}
_entityScriptActivator?.PlayLegacyPending(
message.Guid,
message.ScriptDid,
anchor);
}
private void UpdateSkyPes(
@ -8084,7 +8221,7 @@ public sealed class GameWindow : IDisposable
// landblocks are loaded into GpuWorldState before live-session
// CreateObject events drain. The earlier order (live tick first,
// streaming tick second) caused the initial CreateObject flood from
// login to land before any landblock was loaded; AppendLiveEntity
// login to land before any landblock was loaded; live projection placement
// is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were
// silently dropped on the first frame and never rendered.
//
@ -8235,7 +8372,7 @@ public sealed class GameWindow : IDisposable
{
uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF);
foreach (var entity in rescued)
_worldState.AppendLiveEntity(centerLb, entity);
_liveEntities?.RebucketLiveEntity(entity.ServerGuid, centerLb);
}
}
@ -8483,14 +8620,14 @@ public sealed class GameWindow : IDisposable
// teleport's rescue/re-inject (GpuWorldState.DrainRescued) already
// placed at the destination center — back into the now-UNLOADED
// SOURCE landblock's pending bucket, where nothing recovers it
// (RelocateEntity only scans _loaded). Net: the avatar vanishes
// (the old relocate path only scanned _loaded). Net: the avatar vanishes
// after teleporting out. The teleport machinery owns the avatar's
// landblock during transit; per-frame relocation resumes at
// FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561
// APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING ->
// DRAWSET ABSENT, never recovered.
if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace)
_worldState.RelocateEntity(pe, currentLb);
_liveEntities?.RebucketLiveEntity(pe.ServerGuid, currentLb);
}
// Update chase camera(s). The CameraController exposes whichever
@ -10072,6 +10209,15 @@ public sealed class GameWindow : IDisposable
// exactly matching the old scan's miss case.
uint serverGuid = ae.Entity.ServerGuid;
// Retail CPhysicsObj::UpdateObjectInternal skips root movement,
// MovementManager use, and PartArray animation while cell == 0.
// Pickup/parent leave-world preserves the same owners (including
// PhysicsScript/particles, ticked elsewhere) but must not keep the
// withdrawn body animating in its former cell.
if (serverGuid != 0
&& _liveEntities?.ShouldAdvanceRootRuntime(serverGuid) == false)
continue;
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
// entities; without integration, remote chars jitter-hop between
@ -12171,7 +12317,7 @@ public sealed class GameWindow : IDisposable
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|| _selection.SelectedObjectId is not uint selected
|| !IsLiveHostileMonsterTarget(selected)
|| !_entitiesByServerGuid.TryGetValue(selected, out var target))
|| !_visibleEntitiesByServerGuid.TryGetValue(selected, out var target))
return null;
return target.Position
@ -12221,7 +12367,7 @@ public sealed class GameWindow : IDisposable
mouseX: mouseX, mouseY: mouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,
candidates: _visibleEntitiesByServerGuid.Values,
skipServerGuid: includeSelf ? 0u : _playerServerGuid,
// Resolver: Setup's SelectionSphere is the ONLY input. If the
// entity's Setup didn't bake a SelectionSphere, return null —
@ -12566,7 +12712,7 @@ public sealed class GameWindow : IDisposable
private bool IsCloseRangeTarget(uint targetGuid)
{
if (_playerController is null) return false;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return false;
// Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
@ -12592,7 +12738,7 @@ public sealed class GameWindow : IDisposable
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
if (_playerController is not { } pc || pc.MoveTo is null) return;
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
if (!_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
return;
// Per-type use radius — same heuristic as the picker's
@ -12683,7 +12829,7 @@ public sealed class GameWindow : IDisposable
uint? bestGuid = null;
float bestDistanceSq = float.PositiveInfinity;
foreach (var (guid, entity) in _entitiesByServerGuid)
foreach (var (guid, entity) in _visibleEntitiesByServerGuid)
{
if (!IsLiveHostileMonsterTarget(guid))
continue;
@ -12723,10 +12869,10 @@ public sealed class GameWindow : IDisposable
{
if (guid == _playerServerGuid)
return false;
if (!_entitiesByServerGuid.ContainsKey(guid))
if (!_visibleEntitiesByServerGuid.ContainsKey(guid))
return false;
if (_entitiesByServerGuid.TryGetValue(guid, out var entity)
if (_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)
&& _animatedEntities.TryGetValue(entity.Id, out var animated)
&& animated.Sequencer?.CurrentMotion == AcDream.Core.Physics.MotionCommand.Dead)
return false;
@ -12791,7 +12937,7 @@ public sealed class GameWindow : IDisposable
worldCenter = default;
worldRadius = 0f;
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
if (!_visibleEntitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
if (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
if (spawn.SetupTableId is not uint setupId) return false;
if (_dats is null) return false;
@ -13829,7 +13975,14 @@ public sealed class GameWindow : IDisposable
_equippedChildRenderer?.Dispose();
_liveSessionController?.Dispose();
_liveSession = null;
_inboundPhysics.Clear();
try
{
_liveEntities?.Clear();
}
finally
{
_entityScriptActivator?.ClearLegacyPendingOwners();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8

View file

@ -27,20 +27,17 @@ public sealed record ScriptActivationInfo(
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
/// Handles both server-spawned and dat-hydrated entities, always keyed by
/// canonical local <c>entity.Id</c>. The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
/// </para>
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts
/// + particles. Both are render-thread-only. The activator is invoked from
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
/// For live objects this is invoked by <c>LiveEntityRuntime</c> logical
/// registration/teardown. <c>GpuWorldState</c> invokes it only for dat-static
/// landblock load, promotion, demotion, and unload paths.
/// </para>
///
/// <para>
@ -55,6 +52,7 @@ public sealed class EntityScriptActivator
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly HashSet<uint> _legacyPendingOwners = new();
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
@ -84,17 +82,14 @@ public sealed class EntityScriptActivator
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
/// the script runner, keyed by canonical local <c>entity.Id</c>.
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
uint key = entity.Id;
if (key == 0) return; // malformed entity
var info = _resolver(entity);
@ -119,10 +114,8 @@ public sealed class EntityScriptActivator
/// <summary>
/// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to it. Caller picks
/// the key (the matching ServerGuid for live entities, or
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
/// kill every live emitter the sink has attributed to the canonical
/// local entity id. Idempotent for unknown keys.
/// </summary>
public void OnRemove(uint key)
{
@ -130,4 +123,49 @@ public sealed class EntityScriptActivator
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
}
/// <summary>
/// Starts the temporary server-GUID owner used when F754 precedes the
/// target's CreateObject/materialization. Step 4 replaces this with the
/// retail mixed pending-packet FIFO. Tracking is required because no
/// LiveEntityRecord may exist yet for delete/session teardown.
/// </summary>
public bool PlayLegacyPending(
uint serverGuid,
uint scriptDid,
Vector3 anchorWorldPosition)
{
if (serverGuid == 0)
return false;
bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition);
if (played)
_legacyPendingOwners.Add(serverGuid);
return played;
}
/// <summary>
/// Cleans the temporary server-GUID owner used when F754 arrives before a
/// live projection has a canonical local ID. Step 4 replaces this alias
/// with the retail mixed pending-packet FIFO; until then teardown must stop
/// the alias as well as the normal local owner so an early effect cannot
/// outlive its object incarnation.
/// </summary>
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
{
if (serverGuid == 0 || serverGuid == canonicalLocalId)
return;
if (!_legacyPendingOwners.Remove(serverGuid))
return;
OnRemove(serverGuid);
}
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
public void ClearLegacyPendingOwners()
{
foreach (uint serverGuid in _legacyPendingOwners)
OnRemove(serverGuid);
_legacyPendingOwners.Clear();
}
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
}

View file

@ -16,7 +16,8 @@ namespace AcDream.App.Streaming;
/// <para>
/// Replaces the pre-streaming flat <c>_entities</c> list. This class is the
/// single point of truth for "what's in the world right now" and the only
/// thing that mutates it.
/// thing that mutates spatial buckets. Logical live-object identity and
/// create-time resources are owned by <see cref="AcDream.App.World.LiveEntityRuntime"/>.
/// </para>
///
/// <para>
@ -25,13 +26,13 @@ namespace AcDream.App.Streaming;
/// X is loaded into <see cref="_loaded"/> (frequently true on the first
/// frame after login, where the entire post-login spawn flood drains
/// before the streaming controller has finished loading the visible
/// window). To survive this race, <see cref="AppendLiveEntity"/> stores
/// window). To survive this race, <see cref="PlaceLiveEntityProjection"/> stores
/// orphaned spawns in a per-landblock pending bucket. When
/// <see cref="AddLandblock"/> later loads the landblock, the matching
/// pending entries are merged into the loaded record before the flat
/// view rebuild. <see cref="RemoveLandblock"/> drops pending entries for
/// the same landblock — if the landblock just left the visible window,
/// any spawns that came with it are no longer relevant.
/// view rebuild. <see cref="RemoveLandblock"/> retains non-persistent live
/// entries in that pending bucket so a later reload restores the same identity;
/// only dat-static entries are discarded with streaming residence.
/// </para>
///
/// <remarks>
@ -41,7 +42,6 @@ namespace AcDream.App.Streaming;
public sealed class GpuWorldState
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
/// <summary>
@ -58,12 +58,10 @@ public sealed class GpuWorldState
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
@ -89,11 +87,15 @@ public sealed class GpuWorldState
// rebuilt on each add/remove. The renderer holds a reference to this
// list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
private readonly HashSet<uint> _visibleLiveGuids = new();
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
/// <summary>
/// Try to grab the loaded record for a landblock — useful for callers
@ -219,8 +221,9 @@ public sealed class GpuWorldState
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// Live entities (ServerGuid!=0) already had OnCreate fired at
// AppendLiveEntity; the filter avoids double-firing pending-bucket merges.
// LiveEntityRuntime owns activation for live objects. This static-only
// filter avoids replaying their defaults when a pending projection is
// drained into a newly loaded landblock.
if (_entityScriptActivator is not null)
{
var loadedEntities = _loaded[landblock.LandblockId].Entities;
@ -251,7 +254,7 @@ public sealed class GpuWorldState
/// the entity stays in the spawn landblock and gets frustum-culled when
/// the player walks away.
/// </summary>
public void RelocateEntity(WorldEntity entity, uint newCanonicalLb)
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
{
if (entity.ServerGuid == 0) return;
@ -275,19 +278,19 @@ public sealed class GpuWorldState
// the player stranded, hidden, even after its landblock finished loading
// (the AddLandblock pending-drain had already run empty before the churn
// re-parked the player, and the player is excluded from the server-object
// re-hydrate — so RelocateEntity was the ONLY path that could recover it,
// re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it,
// and it couldn't reach a pending entity). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise.
RemoveEntityFromAllBuckets(entity);
AppendLiveEntity(canonical, entity);
PlaceLiveEntityProjection(canonical, entity);
}
/// <summary>
/// Remove <paramref name="entity"/> (by reference) from whichever
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
/// currently occupies. At most one bucket holds a given entity, so this
/// stops after the first hit. Called by <see cref="RelocateEntity"/> before
/// stops after the first hit. Called by <see cref="RebucketLiveEntity"/> before
/// re-appending, so a stranded pending entity can be promoted.
/// </summary>
private void RemoveEntityFromAllBuckets(WorldEntity entity)
@ -304,6 +307,7 @@ public sealed class GpuWorldState
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
RebuildFlatView();
return;
}
}
@ -318,28 +322,44 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId)
{
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
// A logical live object survives streaming residence. Non-persistent
// projections move to the pending bucket for this landblock and merge
// back as the same WorldEntity when it reloads. Persistent projections
// (the local player) are rescued because their current bucket may be a
// different landblock by the time the caller reinjects them.
var retainedLive = new List<WorldEntity>();
void RetainOrRescue(WorldEntity entity, string source)
{
if (entity.ServerGuid == 0)
return;
if (_persistentGuids.Contains(entity.ServerGuid))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log(
$"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from={source} lb=0x{canonical:X8}");
return;
}
if (!retainedLive.Any(existing => ReferenceEquals(existing, entity)))
retainedLive.Add(entity);
}
// Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for
// re-injecting them (via AppendLiveEntity) into whatever landblock
// re-injecting them through LiveEntityRuntime rebucketing into whatever landblock
// the player is currently on.
if (_loaded.TryGetValue(landblockId, out var lb))
if (_loaded.TryGetValue(canonical, out var lb))
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=loaded lb=0x{landblockId:X8}");
}
}
RetainOrRescue(entity, "loaded");
// C.1.5b: stop DefaultScript for each dat-hydrated entity in
// the landblock. Server-spawned entities are either being
// rescued (script continues at the new LB) or were OnRemove'd
// via RemoveEntityByServerGuid earlier; leave them alone here.
// by LiveEntityRuntime logical teardown; leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
@ -352,7 +372,7 @@ public sealed class GpuWorldState
// #138 (secondary): rescue persistent entities sitting in the PENDING
// bucket too, not just the loaded list. The player is re-injected via
// AppendLiveEntity into its current landblock every frame
// LiveEntityRuntime rebucketing into its current landblock every frame
// (GameWindow's DrainRescued loop); right after a teleport that
// landblock often hasn't streamed in yet, so the player lands in
// _pendingByLandblock. If that same landblock is then unloaded (a
@ -361,22 +381,18 @@ public sealed class GpuWorldState
// "persistent ⇒ survives unload" contract and making the avatar
// vanish after a couple round-trips. Rescue them so DrainRescued
// re-parks them at the next valid landblock.
if (_pendingByLandblock.TryGetValue(landblockId, out var pendingForLb))
if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb))
{
foreach (var entity in pendingForLb)
{
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=pending lb=0x{landblockId:X8}");
}
}
RetainOrRescue(entity, "pending");
}
_pendingByLandblock.Remove(landblockId);
_aabbs.Remove(landblockId);
_pendingByLandblock.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
if (_loaded.Remove(landblockId))
if (_loaded.Remove(canonical))
RebuildFlatView();
}
@ -384,7 +400,7 @@ public sealed class GpuWorldState
/// <summary>
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each via <see cref="AppendLiveEntity"/> with its current position.
/// re-inject each through <c>LiveEntityRuntime.RebucketLiveEntity</c> with its current position.
/// </summary>
public List<WorldEntity> DrainRescued()
{
@ -397,24 +413,16 @@ public sealed class GpuWorldState
/// <summary>
/// Remove every entity with the given <paramref name="serverGuid"/> from
/// all loaded landblocks AND all pending buckets, then rebuild the flat
/// view. Used by the live <c>CreateObject</c> handler to de-duplicate
/// when the server re-sends a spawn (visibility refresh, landblock
/// crossing, etc.). Without this, multiple copies of the same NPC
/// accumulate in the renderer, each with its own <c>PaletteOverride</c>
/// and <c>MeshRefs</c> — producing "NPC clothing flickers as I turn the
/// camera" because the depth test picks different duplicates frame-to-frame.
/// view. Called by <c>LiveEntityRuntime</c> before rebucketing, temporary
/// world withdrawal, or logical teardown. It owns no renderer/script
/// lifecycle and is safe for a still-live incarnation.
///
/// Safe to call with a server guid that's not currently present — no-op.
/// </summary>
public void RemoveEntityByServerGuid(uint serverGuid)
public void RemoveLiveEntityProjection(uint serverGuid)
{
if (serverGuid == 0) return;
// Phase N.4 Task 17: release per-instance state for server-spawned
// entities. No-op for atlas-tier entities (never registered).
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
_entityScriptActivator?.OnRemove(serverGuid);
bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
@ -435,8 +443,7 @@ public sealed class GpuWorldState
rebuiltLoaded = true;
}
// Scrub pending buckets too — a duplicate CreateObject may arrive
// while the landblock is still loading.
// Scrub pending buckets too so rebucketing cannot leave a second slot.
foreach (var kvp in _pendingByLandblock)
kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
@ -444,9 +451,8 @@ public sealed class GpuWorldState
}
/// <summary>
/// Append an entity to a specific landblock's slot. Used by the live
/// CreateObject path where the server spawns entities at a server-side
/// position whose landblock may or may not be loaded yet.
/// Place an already-registered live entity in a landblock slot whose
/// terrain may or may not be loaded yet.
///
/// <para>
/// The server's <c>landblockId</c> is in cell-resolved form
@ -468,14 +474,10 @@ public sealed class GpuWorldState
/// </list>
/// </para>
/// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity)
{
// Phase N.4 Task 17: route server-spawned entities through the
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are
// skipped by OnCreate — it returns null immediately for those.
_wbEntitySpawnAdapter?.OnCreate(entity);
_entityScriptActivator?.OnCreate(entity);
// Spatial placement only. LiveEntityRuntime has already registered
// this incarnation's renderer and script resources exactly once.
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
bool probePersistent = EntityVanishProbe.Enabled
&& entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid);
@ -516,20 +518,16 @@ public sealed class GpuWorldState
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier
/// only (procedural scenery, dat-static stabs/buildings) — they never
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
/// The local player and other live server-spawned entities live in their
/// landblock via <c>RelocateEntity</c> per frame and are not affected
/// by Near→Far demotion of dat-static landblock layers.
/// Only dat-static entity layers demote. Live server projections retain
/// their exact WorldEntity and bucket while terrain remains resident; no
/// logical or spatial lifetime callback is replayed.
/// </para>
/// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
// protects against future callers that mirror AppendLiveEntity's
// protects against future callers that mirror live projection placement's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return;
@ -555,11 +553,19 @@ public sealed class GpuWorldState
}
}
WorldEntity[] retainedLive = lb.Entities
.Where(entity => entity.ServerGuid != 0)
.ToArray();
_loaded[canonical] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
retainedLive);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
RebuildFlatView();
}
@ -578,11 +584,11 @@ public sealed class GpuWorldState
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
@ -614,6 +620,22 @@ public sealed class GpuWorldState
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
var nowVisible = new HashSet<uint>(
_flatEntities
.Where(entity => entity.ServerGuid != 0)
.Select(entity => entity.ServerGuid));
foreach (uint guid in _visibleLiveGuids)
{
if (!nowVisible.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, false);
}
foreach (uint guid in nowVisible)
{
if (!_visibleLiveGuids.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, true);
}
_visibleLiveGuids.Clear();
_visibleLiveGuids.UnionWith(nowVisible);
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
}

View file

@ -1,86 +0,0 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Decides which retained server-object spawns to re-hydrate (rebuild render
/// entities for) when a landblock (re)loads. The pure selection half of the
/// #138 fix; <c>GameWindow</c> owns the replay half (it alone can rebuild a
/// mesh from a <c>CreateObject</c>).
///
/// <para>
/// <b>Why this exists (retail/ACE model).</b> A real AC client keeps its
/// <c>weenie_object_table</c> across a teleport and re-projects its rendered
/// world from that table; the server does NOT re-broadcast objects it believes
/// the client already knows (ACE's per-player <c>KnownObjects</c> set is never
/// cleared on a normal teleport — ACE relies on the client retaining the
/// table, cross-checked against <c>references/holtburger</c> +
/// <c>references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs</c>).
/// </para>
///
/// <para>
/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
/// RENDER entities for FPS but retains the accepted immutable spawns in
/// <c>InboundPhysicsStateController.Snapshots</c> (pruned by an explicit server
/// <c>DeleteObject</c> or session teardown). On reload, ACE will
/// not re-send the objects it still thinks we have, so the render side stays
/// empty — the #138 symptom. This selects the retained spawns whose render
/// entity is currently absent so <c>GameWindow</c> can replay them, making
/// re-delivery independent of the server.
/// </para>
/// </summary>
public static class LandblockEntityRehydrator
{
/// <summary>
/// A retained spawn reduced to just the fields the selection needs.
/// <paramref name="HasWorldMesh"/> is true only when the spawn carries both
/// a world position AND a Setup id — i.e. it would actually build a visible
/// render entity (inventory items and setup-less spawns produce none and
/// are skipped, matching <c>OnLiveEntitySpawnedLocked</c>'s own guard).
/// </summary>
public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh);
/// <summary>
/// Canonical landblock id (low 16 bits forced to <c>0xFFFF</c>) — the same
/// keying <see cref="GpuWorldState.AppendLiveEntity"/> uses, so a
/// cell-resolved spawn id (<c>0xAAAA00CC</c>) and a streamed landblock id
/// (<c>0xAAAAFFFF</c>) compare equal when they name the same landblock.
/// </summary>
public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu;
/// <summary>
/// Select the guids whose retained spawn should be replayed for the
/// just-loaded <paramref name="loadedLandblockId"/>.
/// </summary>
/// <param name="loadedLandblockId">The landblock that just (re)loaded.</param>
/// <param name="retainedSpawns">Snapshot of the retained world-object spawns.</param>
/// <param name="presentServerGuids">
/// Server guids that already have a live render entity in
/// <see cref="GpuWorldState"/>. A guid here was either never dropped or was
/// already re-delivered by the server, so replaying it would be redundant.
/// </param>
/// <param name="playerServerGuid">
/// The local player's guid — never re-hydrated here; it is owned by the
/// persistent-entity rescue path (<see cref="GpuWorldState.MarkPersistent"/>
/// + <c>DrainRescued</c>), which preserves the player's live pose/position
/// rather than resetting it to the spawn snapshot.
/// </param>
public static List<uint> SelectGuidsToRehydrate(
uint loadedLandblockId,
IReadOnlyCollection<RetainedSpawn> retainedSpawns,
IReadOnlySet<uint> presentServerGuids,
uint playerServerGuid)
{
uint loadedCanonical = Canonicalize(loadedLandblockId);
var result = new List<uint>();
foreach (var s in retainedSpawns)
{
if (!s.HasWorldMesh) continue; // no visible entity to rebuild
if (s.Guid == playerServerGuid) continue; // player: persistent-rescue path owns it
if (Canonicalize(s.SpawnLandblockId) != loadedCanonical) continue; // different landblock
if (presentServerGuids.Contains(s.Guid)) continue; // already rendered
result.Add(s.Guid);
}
return result;
}
}

View file

@ -454,7 +454,7 @@ public sealed class StreamingController
case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
// #138: after the landblock is in _loaded (so AppendLiveEntity
// #138: after the landblock is in _loaded (so live projection
// hot-paths), restore any retained server objects ACE won't
// re-send. Fired AFTER AddLandblock, never before.
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);

View file

@ -0,0 +1,732 @@
using AcDream.App.Streaming;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.World;
/// <summary>
/// Animation state owned by a live object. The concrete App animation runtime
/// implements this seam so identity storage does not depend on a render backend.
/// </summary>
public interface ILiveEntityAnimationRuntime
{
WorldEntity Entity { get; }
}
/// <summary>Remote motion state owned by a live object.</summary>
public interface ILiveEntityRemoteMotionRuntime
{
PhysicsBody Body { get; }
}
/// <summary>Marker for the projectile runtime added by the projectile phase.</summary>
public interface ILiveEntityProjectileRuntime { }
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
public interface ILiveEntityEffectProfile { }
public enum LiveEntityProjectionKind
{
World,
Attached,
}
/// <summary>
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
/// Spatial bucketing is deliberately absent: registering or removing meshes,
/// scripts, and effects is a logical-lifetime operation, not a landblock move.
/// </summary>
public interface ILiveEntityResourceLifecycle
{
void Register(WorldEntity entity);
void Unregister(WorldEntity entity);
}
/// <summary>Delegate adapter used by the App composition root.</summary>
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
{
private readonly Action<WorldEntity> _register;
private readonly Action<WorldEntity> _unregister;
public DelegateLiveEntityResourceLifecycle(
Action<WorldEntity> register,
Action<WorldEntity> unregister)
{
_register = register ?? throw new ArgumentNullException(nameof(register));
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
}
public void Register(WorldEntity entity) => _register(entity);
public void Unregister(WorldEntity entity) => _unregister(entity);
}
/// <summary>
/// The one logical record for a server object incarnation. The record survives
/// loaded/pending landblock movement and temporary loss of its render bucket.
/// Only an accepted DeleteObject, session reset, or newer INSTANCE_TS ends it.
/// </summary>
public sealed class LiveEntityRecord
{
internal LiveEntityRecord(WorldSession.EntitySpawn snapshot)
{
ServerGuid = snapshot.Guid;
Snapshot = snapshot;
RefreshDerivedState();
}
public uint ServerGuid { get; }
public ushort Generation => Snapshot.InstanceSequence;
public WorldSession.EntitySpawn Snapshot { get; internal set; }
public WorldEntity? WorldEntity { get; internal set; }
public uint? LocalEntityId => WorldEntity?.Id;
public uint FullCellId { get; internal set; }
public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
public PhysicsBody? PhysicsBody { get; internal set; }
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; }
public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; }
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
public bool ResourcesRegistered { get; internal set; }
public bool IsSpatiallyProjected { get; internal set; }
public bool IsSpatiallyVisible { get; internal set; }
public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
internal void RefreshDerivedState(bool refreshPosition = true)
{
if (refreshPosition && Snapshot.Position is { } position)
{
FullCellId = position.LandblockId;
CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu;
}
RawPhysicsState = Snapshot.Physics?.RawState
?? Snapshot.PhysicsState
?? 0u;
FinalPhysicsState = (PhysicsStateFlags)RawPhysicsState;
}
}
public readonly record struct LiveEntityRegistrationResult(
InboundCreateResult Inbound,
LiveEntityRecord? Record,
bool LogicalRegistrationCreated,
bool ReplacedExistingGeneration,
Exception? PriorGenerationCleanupFailure = null);
/// <summary>
/// Update-thread owner of live server-object identity, accepted state, runtime
/// components, and logical teardown. <see cref="GpuWorldState"/> is a spatial
/// projection only; moving between loaded and pending buckets never replays a
/// create-time renderer, script, animation, or effect action.
///
/// <para>
/// Retail keeps one object per accepted INSTANCE_TS: same-generation
/// CreateObject branches mutate it, Pickup leaves world without destruction,
/// and Delete/instance replacement end it. See SmartBox CreateObject/event
/// pseudocode in <c>2026-07-13-retail-projectile-vfx-pseudocode.md</c>,
/// <c>CPhysicsObj::set_description</c> (0x00514F40),
/// <c>CPhysicsObj::change_cell</c> (0x00513390), and
/// <c>CPhysicsObj::exit_world</c> (0x00514E60).
/// </para>
/// </summary>
public sealed class LiveEntityRuntime
{
public const uint FirstLiveEntityId = 1_000_000u;
public const uint LastLiveEntityId = 0x3FFF_FFFFu;
private readonly GpuWorldState _spatial;
private readonly ILiveEntityResourceLifecycle _resources;
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
private readonly InboundPhysicsStateController _inbound = new();
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
private readonly Dictionary<uint, WorldEntity> _materializedWorldEntitiesByGuid = new();
private readonly Dictionary<uint, WorldEntity> _visibleWorldEntitiesByGuid = new();
private readonly Dictionary<uint, uint> _guidByLocalId = new();
private readonly Dictionary<uint, ILiveEntityAnimationRuntime> _animationsByLocalId = new();
private readonly Dictionary<uint, ILiveEntityRemoteMotionRuntime> _remoteMotionByGuid = new();
private uint _nextLocalEntityId;
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord>? tearDownRuntimeComponents = null,
uint firstLocalEntityId = FirstLiveEntityId)
{
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_tearDownRuntimeComponents = tearDownRuntimeComponents;
if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId)
throw new ArgumentOutOfRangeException(
nameof(firstLocalEntityId),
$"Live entity ids must stay in 0x{FirstLiveEntityId:X8}..0x{LastLiveEntityId:X8}.");
_nextLocalEntityId = firstLocalEntityId;
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
}
public int Count => _recordsByGuid.Count;
public int MaterializedCount => _guidByLocalId.Count;
public IReadOnlyCollection<LiveEntityRecord> Records => _recordsByGuid.Values;
/// <summary>
/// Every materialized top-level world projection, including an object
/// parked in a pending (currently unloaded) spatial bucket. Network and
/// physics mutation must use this stable view so streaming visibility can
/// never masquerade as logical destruction.
/// </summary>
public IReadOnlyDictionary<uint, WorldEntity> MaterializedWorldEntities =>
_materializedWorldEntitiesByGuid;
/// <summary>
/// Currently visible top-level world projections. Attached children and
/// pending projections are excluded from radar, picking, status, and target
/// candidate consumers.
/// </summary>
public IReadOnlyDictionary<uint, WorldEntity> WorldEntities =>
_visibleWorldEntitiesByGuid;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
public IReadOnlyDictionary<uint, ILiveEntityAnimationRuntime> AnimationRuntimes => _animationsByLocalId;
public IReadOnlyDictionary<uint, ILiveEntityRemoteMotionRuntime> RemoteMotionRuntimes => _remoteMotionByGuid;
public ParentAttachmentState ParentAttachments { get; } = new();
public LiveEntityRegistrationResult RegisterLiveEntity(WorldSession.EntitySpawn incoming)
{
InboundCreateResult result = _inbound.AcceptCreate(incoming);
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration)
return new LiveEntityRegistrationResult(result, null, false, false);
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
{
if (_recordsByGuid.TryGetValue(incoming.Guid, out LiveEntityRecord? retained))
{
retained.Snapshot = result.Snapshot;
retained.RefreshDerivedState();
return new LiveEntityRegistrationResult(result, retained, false, false);
}
// Defensive repair for a caller that cleared only the logical map.
// Normal session teardown clears both owners together.
var recovered = new LiveEntityRecord(result.Snapshot);
_recordsByGuid.Add(incoming.Guid, recovered);
return new LiveEntityRegistrationResult(result, recovered, true, false);
}
bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old);
Exception? tearDownFailure = null;
if (old is not null)
{
try
{
TearDownRecord(old);
}
catch (Exception error)
{
tearDownFailure = error;
}
}
if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration)
ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence);
var record = new LiveEntityRecord(result.Snapshot);
_recordsByGuid.Add(incoming.Guid, record);
return new LiveEntityRegistrationResult(
result,
record,
true,
replaced,
tearDownFailure);
}
/// <summary>
/// Completes logical registration when a renderable projection can be
/// hydrated. The factory is called at most once for the incarnation.
/// </summary>
public WorldEntity? MaterializeLiveEntity(
uint serverGuid,
uint fullCellId,
Func<uint, WorldEntity> factory,
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
{
ArgumentNullException.ThrowIfNull(factory);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
return null;
if (record.WorldEntity is null)
{
uint localId = ReserveLocalEntityId();
WorldEntity entity = factory(localId)
?? throw new InvalidOperationException("A live-entity projection factory returned null.");
if (entity.Id != localId)
throw new InvalidOperationException(
$"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}.");
if (entity.ServerGuid != serverGuid)
throw new InvalidOperationException(
$"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}.");
_guidByLocalId.Add(localId, serverGuid);
record.WorldEntity = entity;
try
{
_resources.Register(entity);
record.ResourcesRegistered = true;
}
catch (Exception registrationError)
{
// Registration is an atomic logical-lifetime boundary. Give
// composite owners a symmetric rollback opportunity, then
// remove the identity even if cleanup itself fails.
try
{
_resources.Unregister(entity);
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Live entity resource registration and rollback both failed.",
registrationError,
rollbackError);
}
finally
{
_guidByLocalId.Remove(localId);
record.WorldEntity = null;
record.ResourcesRegistered = false;
}
throw;
}
}
record.ProjectionKind = projectionKind;
RebucketLiveEntity(serverGuid, fullCellId);
return record.WorldEntity;
}
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
return false;
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId);
bool visible = _spatial.IsLiveEntityVisible(serverGuid);
record.IsSpatiallyVisible = visible;
if (record.ProjectionKind is LiveEntityProjectionKind.World)
_materializedWorldEntitiesByGuid[serverGuid] = entity;
else
_materializedWorldEntitiesByGuid.Remove(serverGuid);
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
record.FullCellId = spatialCellOrLandblockId;
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
record.IsSpatiallyProjected = true;
return true;
}
/// <summary>
/// Removes only the render-bucket reference. The logical record and every
/// create-time resource remain alive for later projection/rebucketing.
/// </summary>
public bool WithdrawLiveEntityProjection(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null)
return false;
_spatial.RemoveLiveEntityProjection(serverGuid);
_materializedWorldEntitiesByGuid.Remove(serverGuid);
_visibleWorldEntitiesByGuid.Remove(serverGuid);
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
return true;
}
public bool UnregisterLiveEntity(
DeleteObject.Parsed delete,
bool isLocalPlayer,
Action? beforeTeardown = null)
{
if (!_inbound.TryDelete(delete, isLocalPlayer))
return false;
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
List<Exception>? failures = null;
try
{
beforeTeardown?.Invoke();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
if (_recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? record))
{
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
$"Live entity 0x{delete.Guid:X8} deletion cleanup failed.",
failures);
return true;
}
public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) =>
_recordsByGuid.TryGetValue(serverGuid, out record!);
public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is { } found)
{
entity = found;
return true;
}
entity = null!;
return false;
}
public bool ContainsWorldEntity(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.WorldEntity is not null;
public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) =>
_guidByLocalId.TryGetValue(localEntityId, out serverGuid);
public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId)
{
if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.LocalEntityId is { } found)
{
localEntityId = found;
return true;
}
localEntityId = 0;
return false;
}
public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized.");
if (!ReferenceEquals(runtime.Entity, entity))
throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity.");
if (record.AnimationRuntime is not null)
_animationsByLocalId.Remove(entity.Id);
record.AnimationRuntime = runtime;
_animationsByLocalId[entity.Id] = runtime;
}
public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) =>
_animationsByLocalId.TryGetValue(localEntityId, out runtime!);
public bool ClearAnimationRuntime(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.AnimationRuntime is null)
return false;
if (record.LocalEntityId is { } localId)
_animationsByLocalId.Remove(localId);
record.AnimationRuntime = null;
return true;
}
public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
record.RemoteMotionRuntime = runtime;
record.PhysicsBody = runtime.Body;
_remoteMotionByGuid[serverGuid] = runtime;
}
public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) =>
_remoteMotionByGuid.TryGetValue(serverGuid, out runtime!);
public bool ClearRemoteMotionRuntime(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.RemoteMotionRuntime is null)
return false;
_remoteMotionByGuid.Remove(serverGuid);
record.RemoteMotionRuntime = null;
record.PhysicsBody = null;
return true;
}
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
_inbound.TryGetSnapshot(guid, out spawn);
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyObjDesc(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted);
return applied;
}
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyPickup(update, out accepted);
if (applied)
{
RefreshRecord(update.Guid, accepted);
ClearWorldCell(update.Guid);
ParentAttachments.EndChildProjection(update.Guid);
}
return applied;
}
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyCreateParent(update, out accepted);
if (applied)
{
RefreshRecord(update.ChildGuid, accepted);
ClearWorldCell(update.ChildGuid);
}
return applied;
}
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyParent(update, out accepted);
if (applied)
{
RefreshRecord(update.ChildGuid, accepted);
ClearWorldCell(update.ChildGuid);
}
return applied;
}
public bool TryApplyMotion(
WorldSession.EntityMotionUpdate update,
bool retainPayload,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
RefreshRecord(update.Guid, snapshot);
return applied;
}
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyVector(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted);
return applied;
}
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _inbound.TryApplyState(update, out accepted);
if (applied) RefreshRecord(update.Guid, accepted);
return applied;
}
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
bool isLocalPlayer,
System.Numerics.Quaternion? forcePositionRotation,
System.Numerics.Vector3? currentLocalVelocity,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
bool known = _inbound.TryApplyPosition(
update,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
out disposition,
out accepted,
out timestamps);
if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
{
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition);
if (acceptedPosition)
{
ParentAttachments.EndChildProjection(update.Guid);
}
}
return known;
}
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
_inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
/// <summary>
/// Retail runs root movement/animation only while the object has world-cell
/// membership. A pickup or parent transition keeps script/effect owners but
/// clears the cell and withdraws the root projection until a fresh Position
/// re-enters it.
/// </summary>
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
&& record.IsSpatiallyProjected
&& record.FullCellId != 0;
/// <summary>
/// Claims the one logical top-level spawn notification for this object
/// incarnation. Spatial re-entry must restore presentation without replaying
/// plugin/event registration. An attached-only child claims nothing until it
/// first becomes a top-level world projection.
/// </summary>
public bool TryMarkWorldSpawnPublished(uint serverGuid)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is null
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|| record.WorldSpawnPublished)
return false;
record.WorldSpawnPublished = true;
return true;
}
public void Clear()
{
List<Exception>? failures = null;
foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray())
{
try
{
TearDownRecord(record);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
_recordsByGuid.Clear();
_materializedWorldEntitiesByGuid.Clear();
_visibleWorldEntitiesByGuid.Clear();
_guidByLocalId.Clear();
_animationsByLocalId.Clear();
_remoteMotionByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
if (failures is not null)
throw new AggregateException("One or more live entities failed session teardown.", failures);
}
private void RefreshRecord(
uint guid,
WorldSession.EntitySpawn accepted,
bool refreshPosition = false)
{
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
return;
record.Snapshot = accepted;
record.RefreshDerivedState(refreshPosition);
}
private void ClearWorldCell(uint guid)
{
if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record))
return;
record.FullCellId = 0;
record.CanonicalLandblockId = 0;
}
private uint ReserveLocalEntityId()
{
uint start = _nextLocalEntityId;
do
{
uint candidate = _nextLocalEntityId;
_nextLocalEntityId = candidate == LastLiveEntityId
? FirstLiveEntityId
: candidate + 1u;
if (!_guidByLocalId.ContainsKey(candidate))
return candidate;
}
while (_nextLocalEntityId != start);
throw new InvalidOperationException("The live entity id namespace is exhausted.");
}
private void OnSpatialVisibilityChanged(uint serverGuid, bool visible)
{
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity)
return;
record.IsSpatiallyVisible = visible;
if (visible && record.ProjectionKind is LiveEntityProjectionKind.World)
_visibleWorldEntitiesByGuid[serverGuid] = entity;
else
_visibleWorldEntitiesByGuid.Remove(serverGuid);
}
private void TearDownRecord(LiveEntityRecord record)
{
List<Exception>? failures = null;
void RunCleanup(Action cleanup)
{
try
{
cleanup();
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
if (_tearDownRuntimeComponents is not null)
RunCleanup(() => _tearDownRuntimeComponents(record));
if (record.WorldEntity is { } entity)
{
RunCleanup(() => _spatial.RemoveLiveEntityProjection(record.ServerGuid));
if (record.ResourcesRegistered)
RunCleanup(() => _resources.Unregister(entity));
_guidByLocalId.Remove(entity.Id);
_materializedWorldEntitiesByGuid.Remove(record.ServerGuid);
_visibleWorldEntitiesByGuid.Remove(record.ServerGuid);
_animationsByLocalId.Remove(entity.Id);
}
_remoteMotionByGuid.Remove(record.ServerGuid);
record.AnimationRuntime = null;
record.RemoteMotionRuntime = null;
record.PhysicsBody = null;
record.ProjectileRuntime = null;
record.EffectProfile = null;
record.ResourcesRegistered = false;
record.IsSpatiallyProjected = false;
record.IsSpatiallyVisible = false;
record.WorldSpawnPublished = false;
record.WorldEntity = null;
if (failures is not null)
throw new AggregateException(
$"Live entity 0x{record.ServerGuid:X8} teardown failed.",
failures);
}
}

View file

@ -0,0 +1,97 @@
namespace AcDream.App.World;
/// <summary>
/// Strongly typed, storage-free view over animation components owned by a
/// <see cref="LiveEntityRuntime"/>. This keeps feature loops type-safe without
/// introducing a second component dictionary.
/// </summary>
public sealed class LiveEntityAnimationRuntimeView<TAnimation>
: IEnumerable<KeyValuePair<uint, TAnimation>>
where TAnimation : class, ILiveEntityAnimationRuntime
{
private readonly Func<LiveEntityRuntime?> _runtime;
public LiveEntityAnimationRuntimeView(Func<LiveEntityRuntime?> runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public int Count => _runtime()?.AnimationRuntimes.Count ?? 0;
public IEnumerable<uint> Keys =>
_runtime()?.AnimationRuntimes.Keys ?? Array.Empty<uint>();
public TAnimation this[uint localEntityId]
{
set
{
LiveEntityRuntime runtime = _runtime()
?? throw new InvalidOperationException("Live entity runtime is not initialized.");
if (!runtime.TryGetServerGuid(localEntityId, out uint guid))
throw new InvalidOperationException($"No live entity owns local id 0x{localEntityId:X8}.");
runtime.SetAnimationRuntime(guid, value);
}
}
public bool TryGetValue(uint localEntityId, out TAnimation animation)
{
if (_runtime()?.TryGetAnimationRuntime(localEntityId, out var found) == true
&& found is TAnimation typed)
{
animation = typed;
return true;
}
animation = null!;
return false;
}
public bool Remove(uint localEntityId)
{
LiveEntityRuntime? runtime = _runtime();
return runtime is not null
&& runtime.TryGetServerGuid(localEntityId, out uint guid)
&& runtime.ClearAnimationRuntime(guid);
}
public IEnumerator<KeyValuePair<uint, TAnimation>> GetEnumerator()
{
if (_runtime() is not { } runtime)
yield break;
foreach (var pair in runtime.AnimationRuntimes)
if (pair.Value is TAnimation typed)
yield return new KeyValuePair<uint, TAnimation>(pair.Key, typed);
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>Storage-free typed view over remote-motion components.</summary>
public sealed class LiveEntityRemoteMotionRuntimeView<TRemoteMotion>
where TRemoteMotion : class, ILiveEntityRemoteMotionRuntime
{
private readonly Func<LiveEntityRuntime?> _runtime;
public LiveEntityRemoteMotionRuntimeView(Func<LiveEntityRuntime?> runtime) =>
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public TRemoteMotion this[uint serverGuid]
{
set => (_runtime()
?? throw new InvalidOperationException("Live entity runtime is not initialized."))
.SetRemoteMotionRuntime(serverGuid, value);
}
public bool TryGetValue(uint serverGuid, out TRemoteMotion motion)
{
if (_runtime()?.TryGetRemoteMotionRuntime(serverGuid, out var found) == true
&& found is TRemoteMotion typed)
{
motion = typed;
return true;
}
motion = null!;
return false;
}
public bool Remove(uint serverGuid) =>
_runtime()?.ClearRemoteMotionRuntime(serverGuid) == true;
}

View file

@ -1,7 +1,7 @@
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
namespace AcDream.App.World;
/// <summary>
/// Update-thread state machine for parent relations that may arrive before