fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
This commit is contained in:
parent
2c848d4167
commit
823936ec31
57 changed files with 2551 additions and 324 deletions
123
src/AcDream.App/World/DormantLiveEntityStore.cs
Normal file
123
src/AcDream.App/World/DormantLiveEntityStore.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
internal enum DormantCreateDisposition
|
||||
{
|
||||
NoDormantRecord,
|
||||
ExistingGeneration,
|
||||
NewGeneration,
|
||||
StaleGeneration,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cold, data-only ownership for ACE objects that left the client's active
|
||||
/// visibility set. Active render, physics, animation, effect, and collision
|
||||
/// owners are torn down before a snapshot enters this store.
|
||||
///
|
||||
/// ACE retains KnownObjects across ordinary teleports and does not reliably
|
||||
/// repeat CreateObject when a destination is revisited. Keeping the accepted
|
||||
/// CreateObject snapshot therefore preserves the server object's identity
|
||||
/// without retaining any frame-time or GPU work.
|
||||
/// </summary>
|
||||
internal sealed class DormantLiveEntityStore
|
||||
{
|
||||
private readonly Dictionary<uint, WorldSession.EntitySpawn> _spawns = new();
|
||||
|
||||
public int Count => _spawns.Count;
|
||||
|
||||
public DormantCreateDisposition ClassifyCreate(
|
||||
WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
if (!_spawns.TryGetValue(incoming.Guid, out WorldSession.EntitySpawn retained))
|
||||
return DormantCreateDisposition.NoDormantRecord;
|
||||
|
||||
if (PhysicsTimestampGate.IsNewer(
|
||||
retained.InstanceSequence,
|
||||
incoming.InstanceSequence))
|
||||
{
|
||||
return DormantCreateDisposition.NewGeneration;
|
||||
}
|
||||
|
||||
if (PhysicsTimestampGate.IsNewer(
|
||||
incoming.InstanceSequence,
|
||||
retained.InstanceSequence))
|
||||
{
|
||||
return DormantCreateDisposition.StaleGeneration;
|
||||
}
|
||||
|
||||
return DormantCreateDisposition.ExistingGeneration;
|
||||
}
|
||||
|
||||
public void Retain(WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
if (_spawns.TryGetValue(snapshot.Guid, out WorldSession.EntitySpawn current)
|
||||
&& PhysicsTimestampGate.IsNewer(
|
||||
snapshot.InstanceSequence,
|
||||
current.InstanceSequence))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_spawns[snapshot.Guid] = snapshot;
|
||||
}
|
||||
|
||||
public bool RemoveThroughAcceptedCreate(WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
DormantCreateDisposition disposition = ClassifyCreate(accepted);
|
||||
if (disposition is DormantCreateDisposition.StaleGeneration
|
||||
or DormantCreateDisposition.NoDormantRecord)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _spawns.Remove(accepted.Guid);
|
||||
}
|
||||
|
||||
public bool RemoveExact(DeleteObject.Parsed delete)
|
||||
{
|
||||
if (!_spawns.TryGetValue(delete.Guid, out WorldSession.EntitySpawn retained)
|
||||
|| retained.InstanceSequence != delete.InstanceSequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _spawns.Remove(delete.Guid);
|
||||
}
|
||||
|
||||
public WorldSession.EntitySpawn[] SnapshotLandblock(uint landblockId)
|
||||
{
|
||||
uint canonical = CanonicalLandblock(landblockId);
|
||||
return _spawns.Values
|
||||
.Where(spawn => spawn.Position is { } position
|
||||
&& CanonicalLandblock(position.LandblockId) == canonical
|
||||
&& spawn.SetupTableId is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool Contains(uint guid, ushort generation) =>
|
||||
_spawns.TryGetValue(guid, out WorldSession.EntitySpawn spawn)
|
||||
&& spawn.InstanceSequence == generation;
|
||||
|
||||
public bool TryGetExact(
|
||||
uint guid,
|
||||
ushort generation,
|
||||
out WorldSession.EntitySpawn snapshot)
|
||||
{
|
||||
if (_spawns.TryGetValue(guid, out snapshot)
|
||||
&& snapshot.InstanceSequence == generation)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Clear() => _spawns.Clear();
|
||||
|
||||
private static uint CanonicalLandblock(uint cellId) =>
|
||||
(cellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
|||
private readonly ClientObjectTable _objects;
|
||||
private readonly ILiveEntityTeardownCoordinator _teardown;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly DormantLiveEntityStore _dormant;
|
||||
private readonly Action<string>? _diagnostic;
|
||||
|
||||
public LiveEntityDeletionController(
|
||||
|
|
@ -27,12 +28,14 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
|||
ClientObjectTable objects,
|
||||
ILiveEntityTeardownCoordinator teardown,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
DormantLiveEntityStore? dormant = null,
|
||||
Action<string>? diagnostic = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_dormant = dormant ?? new DormantLiveEntityStore();
|
||||
_diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +49,8 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
|||
if (delete.Guid == _identity.ServerGuid)
|
||||
return false;
|
||||
|
||||
if (!_runtime.TryGetRecord(delete.Guid, out _))
|
||||
bool hasActiveRecord = _runtime.TryGetRecord(delete.Guid, out _);
|
||||
if (!hasActiveRecord)
|
||||
_teardown.ForgetUnknownOwner(delete.Guid);
|
||||
|
||||
bool removed = _runtime.UnregisterLiveEntity(
|
||||
|
|
@ -54,16 +58,45 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink
|
|||
isLocalPlayer: false,
|
||||
beforeTeardown: () =>
|
||||
ObjectTableWiring.ApplyEntityDelete(_objects, delete));
|
||||
bool removedDormant = _dormant.RemoveExact(delete);
|
||||
if (!removed && removedDormant)
|
||||
ObjectTableWiring.ApplyEntityDelete(_objects, delete);
|
||||
if (removed)
|
||||
{
|
||||
_dormant.RemoveExact(delete);
|
||||
_diagnostic?.Invoke(
|
||||
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
|
||||
}
|
||||
return removed;
|
||||
else if (removedDormant)
|
||||
{
|
||||
_diagnostic?.Invoke(
|
||||
$"live: delete dormant guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
|
||||
}
|
||||
return removed || removedDormant;
|
||||
}
|
||||
|
||||
public bool Prune(LiveEntityPruneCandidate candidate) =>
|
||||
Delete(new DeleteObject.Parsed(
|
||||
candidate.ServerGuid,
|
||||
candidate.Generation));
|
||||
public bool Prune(LiveEntityPruneCandidate candidate)
|
||||
{
|
||||
if (!_runtime.TryGetRecord(
|
||||
candidate.ServerGuid,
|
||||
out LiveEntityRecord record)
|
||||
|| record.Generation != candidate.Generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WorldSession.EntitySpawn snapshot = record.Snapshot;
|
||||
bool removed = _runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(
|
||||
candidate.ServerGuid,
|
||||
candidate.Generation),
|
||||
isLocalPlayer: false);
|
||||
if (!removed)
|
||||
return false;
|
||||
|
||||
_dormant.Retain(snapshot);
|
||||
_diagnostic?.Invoke(
|
||||
$"live: dormant guid=0x{candidate.ServerGuid:X8} instSeq={candidate.Generation}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
private readonly IAcceptedLocalPhysicsTimestampPublisher _timestamps;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveEntityDeletionController _deletion;
|
||||
private readonly DormantLiveEntityStore _dormant;
|
||||
private readonly Action<string>? _diagnostic;
|
||||
|
||||
public LiveEntityHydrationController(
|
||||
|
|
@ -192,6 +193,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
IAcceptedLocalPhysicsTimestampPublisher timestamps,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveEntityDeletionController deletion,
|
||||
DormantLiveEntityStore? dormant = null,
|
||||
Action<string>? diagnostic = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
|
|
@ -205,12 +207,41 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
_timestamps = timestamps ?? throw new ArgumentNullException(nameof(timestamps));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_deletion = deletion ?? throw new ArgumentNullException(nameof(deletion));
|
||||
_dormant = dormant ?? new DormantLiveEntityStore();
|
||||
_diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
internal event Action<uint>? AppearanceApplied;
|
||||
|
||||
public void OnCreate(WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
DormantCreateDisposition dormantDisposition =
|
||||
_dormant.ClassifyCreate(spawn);
|
||||
if (dormantDisposition is DormantCreateDisposition.StaleGeneration)
|
||||
return;
|
||||
|
||||
// A same-incarnation packet must advance from the exact accepted
|
||||
// dormant timestamps, not seed a fresh gate from whichever packet
|
||||
// happened to wake the object. Rehydrate the cold canonical snapshot,
|
||||
// then let the ordinary same-generation path compare every channel.
|
||||
if (dormantDisposition is DormantCreateDisposition.ExistingGeneration
|
||||
&& _dormant.TryGetExact(
|
||||
spawn.Guid,
|
||||
spawn.InstanceSequence,
|
||||
out WorldSession.EntitySpawn retained))
|
||||
{
|
||||
OnCreateCore(retained, dormantDisposition);
|
||||
if (!spawn.Equals(retained))
|
||||
OnCreateCore(spawn, DormantCreateDisposition.NoDormantRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
OnCreateCore(spawn, dormantDisposition);
|
||||
}
|
||||
|
||||
private void OnCreateCore(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
DormantCreateDisposition dormantDisposition)
|
||||
{
|
||||
// DatCollection uses one mutable reader cursor shared with streaming.
|
||||
// Registration, canonical reread, and projection construction remain
|
||||
|
|
@ -228,9 +259,14 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
|
||||
// An accepted retransmit can still be parked behind a retryable
|
||||
// teardown tombstone. It owns no active record and therefore must
|
||||
// not mutate retained qualities or route an update tail.
|
||||
// not mutate retained qualities or route an update tail. Keep its
|
||||
// dormant snapshot until an active record actually accepts
|
||||
// ownership; otherwise a transient teardown failure would discard
|
||||
// the only ACE revisit source.
|
||||
if (registration.Record is not { } record)
|
||||
return;
|
||||
|
||||
_dormant.RemoveThroughAcceptedCreate(spawn);
|
||||
ulong createIntegrationVersion = record.CreateIntegrationVersion;
|
||||
|
||||
try
|
||||
|
|
@ -243,7 +279,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
_objects,
|
||||
spawn,
|
||||
replaceGeneration: result.Disposition is
|
||||
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration,
|
||||
AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration
|
||||
|| dormantDisposition is DormantCreateDisposition.NewGeneration,
|
||||
accepting: () => _runtime.IsCurrentCreateIntegration(
|
||||
record,
|
||||
createIntegrationVersion))
|
||||
|
|
@ -420,6 +457,11 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded
|
|||
/// </summary>
|
||||
public void OnLandblockLoaded(uint loadedLandblockId)
|
||||
{
|
||||
WorldSession.EntitySpawn[] dormant =
|
||||
_dormant.SnapshotLandblock(loadedLandblockId);
|
||||
for (int i = 0; i < dormant.Length; i++)
|
||||
OnCreate(dormant[i]);
|
||||
|
||||
if (_runtime.Count == 0)
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ internal sealed class LiveEntityLivenessController
|
|||
private readonly LiveEntityRuntime _runtime;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly ILiveEntityPruneSink _prune;
|
||||
private readonly DormantLiveEntityStore? _dormant;
|
||||
private readonly LiveEntityLivenessTracker _tracker = new();
|
||||
private readonly List<LiveEntityLivenessSample> _samples = new();
|
||||
private double _nextMaintenanceAt;
|
||||
|
|
@ -96,11 +97,13 @@ internal sealed class LiveEntityLivenessController
|
|||
public LiveEntityLivenessController(
|
||||
LiveEntityRuntime runtime,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
ILiveEntityPruneSink prune)
|
||||
ILiveEntityPruneSink prune,
|
||||
DormantLiveEntityStore? dormant = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_prune = prune ?? throw new ArgumentNullException(nameof(prune));
|
||||
_dormant = dormant;
|
||||
}
|
||||
|
||||
public void Tick(double now)
|
||||
|
|
@ -153,6 +156,7 @@ internal sealed class LiveEntityLivenessController
|
|||
public void Clear()
|
||||
{
|
||||
_tracker.Clear();
|
||||
_dormant?.Clear();
|
||||
_samples.Clear();
|
||||
_nextMaintenanceAt = 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
|
|||
/// 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, newer INSTANCE_TS, or the
|
||||
/// retail 25-second leave-visibility lifecycle ends it.
|
||||
/// active side of the leave-visibility lifecycle ends it. ACE-compatible
|
||||
/// cold snapshot ownership lives outside this active runtime.
|
||||
/// </summary>
|
||||
public sealed class LiveEntityRecord
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue