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:
parent
8a5d77f7f4
commit
8dd996053d
24 changed files with 2449 additions and 631 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue