fix(streaming): retire stale portal-region entities

Port retail's 25-second leave-visibility lifetime over canonical live records, retaining spatially resident and owned entities while using the conservative ACE visibility envelope for nonresident records. Route expiry through the normal generation-safe F747 teardown so animations, effects, physics, and render owners unwind symmetrically.

Replace the append-only modern mesh buffer with coalescing vertex/index ranges and upload each mesh's vertices once instead of once per material. Released zero-reference meshes can now reuse GPU ranges after portal and cache churn.

A connected five-region round trip returned animation ownership to baseline, recreated the starting region on revisit, and held normal FPS. Release build succeeds and all 5,927 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 10:02:15 +02:00
parent 2cbf34a668
commit 3971997689
13 changed files with 918 additions and 117 deletions

View file

@ -460,6 +460,7 @@ public sealed class GameWindow : IDisposable
/// <see cref="OnLiveEntityDeleted"/>.
/// </summary>
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
@ -2535,6 +2536,10 @@ public sealed class GameWindow : IDisposable
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
() => _playerServerGuid,
OnLiveEntityPruned);
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
@ -2879,6 +2884,7 @@ public sealed class GameWindow : IDisposable
_particleVisibility.Reset();
try
{
_liveEntityLiveness?.Clear();
_liveEntities?.Clear();
}
finally
@ -4376,6 +4382,17 @@ public sealed class GameWindow : IDisposable
}
}
private void OnLiveEntityPruned(AcDream.App.World.LiveEntityPruneCandidate candidate)
{
// ACE retains KnownObjects across normal teleports and consequently
// does not issue an F747 when an old region leaves client visibility.
// Route the 25-second client liveness expiry through the exact same
// generation gate and symmetric teardown as a wire ObjectDelete.
OnLiveEntityDeleted(new AcDream.Core.Net.Messages.DeleteObject.Parsed(
candidate.ServerGuid,
candidate.Generation));
}
/// <summary>
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — a creature/player's
/// appearance changed (equip / unequip / tailoring / recipe result /
@ -9142,6 +9159,7 @@ public sealed class GameWindow : IDisposable
// the recall action retires before ACE's Hidden SetState freezes its
// PartArray at the teleport boundary.
_liveFrameCoordinator.Tick(frameDelta);
_liveEntityLiveness?.Tick(ClientTimerNow());
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before

View file

@ -0,0 +1,117 @@
namespace AcDream.App.Rendering.Wb;
internal readonly record struct MeshBufferRange(int Offset, int Length)
{
public int End => checked(Offset + Length);
}
/// <summary>
/// Best-fit allocator for one element-addressed GPU buffer. Released ranges
/// are coalesced immediately so ObjectMeshManager eviction returns storage to
/// the shared modern-rendering buffers instead of only dropping cache keys.
/// </summary>
internal sealed class ContiguousRangeAllocator
{
private readonly List<MeshBufferRange> _free = new();
public ContiguousRangeAllocator(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
Capacity = capacity;
_free.Add(new MeshBufferRange(0, capacity));
}
public int Capacity { get; private set; }
public int Used { get; private set; }
public int HighWaterMark { get; private set; }
public int Free => Capacity - Used;
public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
public bool TryAllocate(int length, out MeshBufferRange allocation)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length);
int bestIndex = -1;
int bestLength = int.MaxValue;
for (int i = 0; i < _free.Count; i++)
{
int candidateLength = _free[i].Length;
if (candidateLength >= length && candidateLength < bestLength)
{
bestIndex = i;
bestLength = candidateLength;
if (candidateLength == length)
break;
}
}
if (bestIndex < 0)
{
allocation = default;
return false;
}
MeshBufferRange free = _free[bestIndex];
allocation = new MeshBufferRange(free.Offset, length);
if (free.Length == length)
_free.RemoveAt(bestIndex);
else
_free[bestIndex] = new MeshBufferRange(free.Offset + length, free.Length - length);
Used = checked(Used + length);
HighWaterMark = Math.Max(HighWaterMark, allocation.End);
return true;
}
public void Grow(int newCapacity)
{
if (newCapacity <= Capacity)
throw new ArgumentOutOfRangeException(nameof(newCapacity));
int oldCapacity = Capacity;
Capacity = newCapacity;
InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
}
public void Release(MeshBufferRange allocation)
{
if (allocation.Length <= 0
|| allocation.Offset < 0
|| allocation.End > Capacity)
{
throw new ArgumentOutOfRangeException(nameof(allocation));
}
InsertAndCoalesce(allocation);
Used = checked(Used - allocation.Length);
}
private void InsertAndCoalesce(MeshBufferRange released)
{
int index = _free.BinarySearch(
released,
Comparer<MeshBufferRange>.Create((left, right) => left.Offset.CompareTo(right.Offset)));
if (index < 0)
index = ~index;
if (index > 0 && _free[index - 1].End > released.Offset)
throw new InvalidOperationException("GPU buffer range was released more than once.");
if (index < _free.Count && released.End > _free[index].Offset)
throw new InvalidOperationException("GPU buffer range overlaps an existing free range.");
int start = released.Offset;
int end = released.End;
if (index > 0 && _free[index - 1].End == start)
{
start = _free[index - 1].Offset;
_free.RemoveAt(--index);
}
if (index < _free.Count && end == _free[index].Offset)
{
end = _free[index].End;
_free.RemoveAt(index);
}
_free.Insert(index, new MeshBufferRange(start, end - start));
}
}

View file

@ -1,128 +1,252 @@
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using System;
namespace AcDream.App.Rendering.Wb {
public class GlobalMeshBuffer : IDisposable {
private readonly GL _gl;
public uint VAO { get; private set; }
public uint VBO { get; private set; }
public uint IBO { get; private set; }
namespace AcDream.App.Rendering.Wb;
private int _vboCapacity = 1024 * 1024; // 1M vertices (~32MB)
private int _iboCapacity = 3 * 1024 * 1024; // 3M indices (~6MB)
private int _vboOffset = 0;
private int _iboOffset = 0;
internal sealed record GlobalMeshAllocation(
MeshBufferRange Vertices,
MeshBufferRange Indices,
IReadOnlyList<int> BatchFirstIndices);
public GlobalMeshBuffer(GL gl) {
_gl = gl;
InitBuffers();
/// <summary>
/// Shared modern-rendering vertex/index buffers with reclaimable ranges.
/// ObjectMeshManager owns allocation lifetime and releases a mesh's ranges
/// when its zero-reference LRU entry is evicted.
/// </summary>
public sealed class GlobalMeshBuffer : IDisposable
{
private readonly GL _gl;
private readonly ContiguousRangeAllocator _vertices;
private readonly ContiguousRangeAllocator _indices;
public uint VAO { get; private set; }
public uint VBO { get; private set; }
public uint IBO { get; private set; }
public GlobalMeshBuffer(GL gl)
{
_gl = gl;
_vertices = new ContiguousRangeAllocator(1024 * 1024); // ~32 MB
_indices = new ContiguousRangeAllocator(3 * 1024 * 1024); // ~6 MB
InitBuffers();
}
private unsafe void InitBuffers()
{
_gl.GenVertexArrays(1, out uint vao);
VAO = vao;
_gl.BindVertexArray(VAO);
_gl.GenBuffers(1, out uint vbo);
VBO = vbo;
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
_gl.BufferData(
GLEnum.ArrayBuffer,
(nuint)(_vertices.Capacity * VertexPositionNormalTexture.Size),
null,
GLEnum.StaticDraw);
int stride = VertexPositionNormalTexture.Size;
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
_gl.GenBuffers(1, out uint ibo);
IBO = ibo;
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
_gl.BufferData(
GLEnum.ElementArrayBuffer,
(nuint)(_indices.Capacity * sizeof(ushort)),
null,
GLEnum.StaticDraw);
_gl.BindVertexArray(0);
}
internal unsafe GlobalMeshAllocation UploadMesh(
VertexPositionNormalTexture[] vertices,
IReadOnlyList<ushort[]> indexBatches)
{
ArgumentNullException.ThrowIfNull(vertices);
ArgumentNullException.ThrowIfNull(indexBatches);
if (vertices.Length == 0)
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
int totalIndices = 0;
for (int i = 0; i < indexBatches.Count; i++)
{
ushort[] batch = indexBatches[i]
?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
totalIndices = checked(totalIndices + batch.Length);
}
if (totalIndices == 0)
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
MeshBufferRange vertexRange = AllocateVertices(vertices.Length);
MeshBufferRange indexRange;
try
{
indexRange = AllocateIndices(totalIndices);
}
catch
{
_vertices.Release(vertexRange);
throw;
}
private unsafe void InitBuffers() {
_gl.GenVertexArrays(1, out uint vao);
VAO = vao;
_gl.BindVertexArray(VAO);
_gl.GenBuffers(1, out uint vbo);
VBO = vbo;
var firstIndices = new int[indexBatches.Count];
try
{
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
_gl.BufferData(GLEnum.ArrayBuffer, (nuint)(_vboCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw);
int stride = VertexPositionNormalTexture.Size;
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
_gl.GenBuffers(1, out uint ibo);
IBO = ibo;
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
_gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(_iboCapacity * sizeof(ushort)), null, GLEnum.StaticDraw);
_gl.BindVertexArray(0);
}
public unsafe (int baseVertex, int firstIndex) Append(VertexPositionNormalTexture[] vertices, ushort[] indices) {
if (vertices.Length == 0 || indices.Length == 0) return (0, 0);
// Check capacity
if (_vboOffset + vertices.Length > _vboCapacity) {
ResizeVBO(Math.Max(_vboCapacity * 2, _vboCapacity + vertices.Length));
}
if (_iboOffset + indices.Length > _iboCapacity) {
ResizeIBO(Math.Max(_iboCapacity * 2, _iboCapacity + indices.Length));
}
int baseVertex = _vboOffset;
int firstIndex = _iboOffset;
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
fixed (VertexPositionNormalTexture* ptr = vertices) {
_gl.BufferSubData(GLEnum.ArrayBuffer, (nint)(baseVertex * VertexPositionNormalTexture.Size), (nuint)(vertices.Length * VertexPositionNormalTexture.Size), ptr);
fixed (VertexPositionNormalTexture* ptr = vertices)
{
_gl.BufferSubData(
GLEnum.ArrayBuffer,
(nint)(vertexRange.Offset * VertexPositionNormalTexture.Size),
(nuint)(vertices.Length * VertexPositionNormalTexture.Size),
ptr);
}
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
fixed (ushort* ptr = indices) {
_gl.BufferSubData(GLEnum.ElementArrayBuffer, (nint)(firstIndex * sizeof(ushort)), (nuint)(indices.Length * sizeof(ushort)), ptr);
int indexOffset = indexRange.Offset;
for (int i = 0; i < indexBatches.Count; i++)
{
ushort[] batch = indexBatches[i];
firstIndices[i] = indexOffset;
if (batch.Length > 0)
{
fixed (ushort* ptr = batch)
{
_gl.BufferSubData(
GLEnum.ElementArrayBuffer,
(nint)(indexOffset * sizeof(ushort)),
(nuint)(batch.Length * sizeof(ushort)),
ptr);
}
indexOffset += batch.Length;
}
}
_vboOffset += vertices.Length;
_iboOffset += indices.Length;
return (baseVertex, firstIndex);
}
catch
{
_indices.Release(indexRange);
_vertices.Release(vertexRange);
throw;
}
private unsafe void ResizeVBO(int newCapacity) {
_gl.GenBuffers(1, out uint newVbo);
_gl.BindBuffer(GLEnum.ArrayBuffer, newVbo);
_gl.BufferData(GLEnum.ArrayBuffer, (nuint)(newCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw);
return new GlobalMeshAllocation(vertexRange, indexRange, firstIndices);
}
_gl.BindBuffer(GLEnum.CopyReadBuffer, VBO);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_vboOffset * VertexPositionNormalTexture.Size));
internal void Release(GlobalMeshAllocation allocation)
{
ArgumentNullException.ThrowIfNull(allocation);
_indices.Release(allocation.Indices);
_vertices.Release(allocation.Vertices);
}
private MeshBufferRange AllocateVertices(int count)
{
if (_vertices.TryAllocate(count, out MeshBufferRange allocation))
return allocation;
int newCapacity = GrowCapacity(_vertices.Capacity, count);
ResizeVBO(newCapacity);
_vertices.Grow(newCapacity);
if (!_vertices.TryAllocate(count, out allocation))
throw new InvalidOperationException("Failed to allocate a vertex range after growing the buffer.");
return allocation;
}
private MeshBufferRange AllocateIndices(int count)
{
if (_indices.TryAllocate(count, out MeshBufferRange allocation))
return allocation;
int newCapacity = GrowCapacity(_indices.Capacity, count);
ResizeIBO(newCapacity);
_indices.Grow(newCapacity);
if (!_indices.TryAllocate(count, out allocation))
throw new InvalidOperationException("Failed to allocate an index range after growing the buffer.");
return allocation;
}
private static int GrowCapacity(int capacity, int requiredContiguousLength)
{
int minimum = checked(capacity + requiredContiguousLength);
int doubled = capacity <= int.MaxValue / 2 ? capacity * 2 : int.MaxValue;
return Math.Max(doubled, minimum);
}
private unsafe void ResizeVBO(int newCapacity)
{
_gl.GenBuffers(1, out uint newVbo);
_gl.BindBuffer(GLEnum.ArrayBuffer, newVbo);
_gl.BufferData(
GLEnum.ArrayBuffer,
(nuint)(newCapacity * VertexPositionNormalTexture.Size),
null,
GLEnum.StaticDraw);
_gl.BindBuffer(GLEnum.CopyReadBuffer, VBO);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(
GLEnum.CopyReadBuffer,
GLEnum.CopyWriteBuffer,
0,
0,
(nuint)(_vertices.HighWaterMark * VertexPositionNormalTexture.Size));
_gl.DeleteBuffer(VBO);
VBO = newVbo;
_gl.BindVertexArray(VAO);
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
int stride = VertexPositionNormalTexture.Size;
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
_gl.BindVertexArray(0);
}
private unsafe void ResizeIBO(int newCapacity)
{
_gl.GenBuffers(1, out uint newIbo);
_gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo);
_gl.BufferData(
GLEnum.ElementArrayBuffer,
(nuint)(newCapacity * sizeof(ushort)),
null,
GLEnum.StaticDraw);
_gl.BindBuffer(GLEnum.CopyReadBuffer, IBO);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo);
_gl.CopyBufferSubData(
GLEnum.CopyReadBuffer,
GLEnum.CopyWriteBuffer,
0,
0,
(nuint)(_indices.HighWaterMark * sizeof(ushort)));
_gl.DeleteBuffer(IBO);
IBO = newIbo;
_gl.BindVertexArray(VAO);
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
_gl.BindVertexArray(0);
}
public void Dispose()
{
if (VAO != 0)
_gl.DeleteVertexArray(VAO);
if (VBO != 0)
_gl.DeleteBuffer(VBO);
VBO = newVbo;
_vboCapacity = newCapacity;
// Re-bind to VAO
_gl.BindVertexArray(VAO);
_gl.BindBuffer(GLEnum.ArrayBuffer, VBO);
int stride = VertexPositionNormalTexture.Size;
_gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
_gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
_gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
_gl.BindVertexArray(0);
}
private unsafe void ResizeIBO(int newCapacity) {
_gl.GenBuffers(1, out uint newIbo);
_gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo);
_gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(newCapacity * sizeof(ushort)), null, GLEnum.StaticDraw);
_gl.BindBuffer(GLEnum.CopyReadBuffer, IBO);
_gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo);
_gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_iboOffset * sizeof(ushort)));
if (IBO != 0)
_gl.DeleteBuffer(IBO);
IBO = newIbo;
_iboCapacity = newCapacity;
// Re-bind to VAO
_gl.BindVertexArray(VAO);
_gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO);
_gl.BindVertexArray(0);
}
public void Dispose() {
if (VAO != 0) _gl.DeleteVertexArray(VAO);
if (VBO != 0) _gl.DeleteBuffer(VBO);
if (IBO != 0) _gl.DeleteBuffer(IBO);
VAO = VBO = IBO = 0;
}
VAO = VBO = IBO = 0;
}
}

View file

@ -29,6 +29,7 @@ namespace AcDream.App.Rendering.Wb {
public uint VBO { get; set; }
public int VertexCount { get; set; }
public List<ObjectRenderBatch> Batches { get; set; } = new();
internal GlobalMeshAllocation? GlobalAllocation { get; set; }
public bool IsSetup { get; set; }
public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new();
@ -712,9 +713,17 @@ namespace AcDream.App.Rendering.Wb {
var gl = _graphicsDevice.GL;
uint vao = 0, vbo = 0;
var modernIndexBatches = meshData.TextureBatches.Values
.SelectMany(batches => batches)
.Where(batch => batch.Indices.Count != 0)
.Select(batch => batch.Indices.ToArray())
.ToArray();
GlobalMeshAllocation? globalAllocation = null;
if (_useModernRendering) {
// Everything goes into the global VBO/IBO
// One mesh owns one vertex range and one contiguous index
// range. The former append path duplicated the full vertex
// array per material and never reclaimed evicted ranges.
vao = GlobalBuffer!.VAO;
vbo = GlobalBuffer!.VBO;
}
@ -787,9 +796,6 @@ namespace AcDream.App.Rendering.Wb {
if (_useModernRendering) {
ibo = GlobalBuffer!.IBO;
var appended = GlobalBuffer.Append(meshData.Vertices, batch.Indices.ToArray());
batchBaseVertex = appended.baseVertex;
firstIndex = (uint)appended.firstIndex;
}
else {
gl.GenBuffers(1, out ibo);
@ -824,11 +830,25 @@ namespace AcDream.App.Rendering.Wb {
}
}
if (_useModernRendering && modernIndexBatches.Length != 0) {
globalAllocation = GlobalBuffer!.UploadMesh(meshData.Vertices, modernIndexBatches);
if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count) {
GlobalBuffer.Release(globalAllocation);
throw new InvalidOperationException("Global mesh batch allocation count mismatch.");
}
for (int i = 0; i < renderBatches.Count; i++) {
renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset;
renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i];
}
}
var renderData = new ObjectRenderData {
VAO = vao,
VBO = vbo,
VertexCount = meshData.Vertices.Length,
Batches = renderBatches,
GlobalAllocation = globalAllocation,
ParticleEmitters = meshData.ParticleEmitters,
DIDDegrade = meshData.DIDDegrade,
CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(),
@ -958,6 +978,10 @@ namespace AcDream.App.Rendering.Wb {
}
}
else {
if (data.GlobalAllocation is { } allocation) {
GlobalBuffer!.Release(allocation);
data.GlobalAllocation = null;
}
foreach (var batch in data.Batches) {
if (batch.Atlas != null) {
batch.Atlas.ReleaseTexture(batch.Key);

View file

@ -0,0 +1,182 @@
using AcDream.Core.Net.Messages;
namespace AcDream.App.World;
internal readonly record struct LiveEntityLivenessSample(
uint ServerGuid,
ushort Generation,
bool IsConservativelyVisible,
bool HasNonWorldRetention);
internal readonly record struct LiveEntityPruneCandidate(
uint ServerGuid,
ushort Generation);
/// <summary>
/// Deadline state for ACE's retained KnownObjects behavior. Retail
/// <c>CPhysicsObj::prepare_to_leave_visibility</c> (0x00511F40) schedules the
/// same 25-second expiry through <c>CObjectMaint::AddObjectToBeDestroyed</c>
/// (0x00508F70), while <c>prepare_to_enter_world</c> (0x00511FA0) cancels it.
/// </summary>
internal sealed class LiveEntityLivenessTracker
{
internal const double DestructionTimeoutSeconds = 25.0;
private readonly Dictionary<uint, Deadline> _deadlines = new();
internal int DeadlineCount => _deadlines.Count;
internal IReadOnlyList<LiveEntityPruneCandidate> Tick(
double now,
IReadOnlyList<LiveEntityLivenessSample> samples)
{
var present = new HashSet<uint>(samples.Count);
var due = new List<LiveEntityPruneCandidate>();
for (int i = 0; i < samples.Count; i++)
{
LiveEntityLivenessSample sample = samples[i];
present.Add(sample.ServerGuid);
if (sample.IsConservativelyVisible || sample.HasNonWorldRetention)
{
_deadlines.Remove(sample.ServerGuid);
continue;
}
if (!_deadlines.TryGetValue(sample.ServerGuid, out Deadline deadline)
|| deadline.Generation != sample.Generation)
{
_deadlines[sample.ServerGuid] = new Deadline(
sample.Generation,
now + DestructionTimeoutSeconds);
continue;
}
if (deadline.ExpiresAt > now)
continue;
due.Add(new LiveEntityPruneCandidate(sample.ServerGuid, sample.Generation));
_deadlines.Remove(sample.ServerGuid);
}
uint[] stale = _deadlines.Keys
.Where(guid => !present.Contains(guid))
.ToArray();
for (int i = 0; i < stale.Length; i++)
_deadlines.Remove(stale[i]);
return due;
}
internal void Clear() => _deadlines.Clear();
private readonly record struct Deadline(ushort Generation, double ExpiresAt);
}
/// <summary>
/// App-layer owner of client-side retained-object liveness. The canonical live
/// entity remains intact while spatially resident, near the player, attached,
/// held, wielded, or in a container. A nonresident top-level world object
/// farther than 384 metres for 25 seconds is torn down through the same
/// generation-safe lifecycle as ObjectDelete. The distance is holtburger's
/// conservative ACE-compatibility envelope until exact ObjCell PVS is wired.
/// </summary>
internal sealed class LiveEntityLivenessController
{
internal const float ConservativeVisibilityDistance = 384f;
private const double MaintenanceIntervalSeconds = 1.0;
private readonly LiveEntityRuntime _runtime;
private readonly Func<uint> _playerGuid;
private readonly Action<LiveEntityPruneCandidate> _prune;
private readonly LiveEntityLivenessTracker _tracker = new();
private readonly List<LiveEntityLivenessSample> _samples = new();
private double _nextMaintenanceAt;
public LiveEntityLivenessController(
LiveEntityRuntime runtime,
Func<uint> playerGuid,
Action<LiveEntityPruneCandidate> prune)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_prune = prune ?? throw new ArgumentNullException(nameof(prune));
}
public void Tick(double now)
{
if (now < _nextMaintenanceAt)
return;
_nextMaintenanceAt = now + MaintenanceIntervalSeconds;
uint playerGuid = _playerGuid();
if (playerGuid == 0
|| !_runtime.TryGetRecord(playerGuid, out LiveEntityRecord player)
|| player.Snapshot.Position is not { } playerPosition)
{
return;
}
_samples.Clear();
foreach (LiveEntityRecord record in _runtime.Records)
{
if (record.ServerGuid == playerGuid
|| record.Snapshot.Position is not { } position)
{
continue;
}
bool retained = record.ProjectionKind is LiveEntityProjectionKind.Attached
|| NonZero(record.Snapshot.ContainerId)
|| NonZero(record.Snapshot.WielderId)
|| NonZero(record.Snapshot.ParentGuid);
_samples.Add(new LiveEntityLivenessSample(
record.ServerGuid,
record.Generation,
record.IsSpatiallyVisible
|| IsWithinConservativeVisibility(playerPosition, position),
retained));
}
IReadOnlyList<LiveEntityPruneCandidate> due = _tracker.Tick(now, _samples);
for (int i = 0; i < due.Count; i++)
{
LiveEntityPruneCandidate candidate = due[i];
if (_runtime.TryGetRecord(candidate.ServerGuid, out LiveEntityRecord current)
&& current.Generation == candidate.Generation)
{
_prune(candidate);
}
}
}
public void Clear()
{
_tracker.Clear();
_samples.Clear();
_nextMaintenanceAt = 0;
}
internal static bool IsWithinConservativeVisibility(
CreateObject.ServerPosition player,
CreateObject.ServerPosition entity)
{
(double playerX, double playerY) = GlobalXY(player);
(double entityX, double entityY) = GlobalXY(entity);
double dx = entityX - playerX;
double dy = entityY - playerY;
double dz = entity.PositionZ - player.PositionZ;
double maximum = ConservativeVisibilityDistance;
return dx * dx + dy * dy + dz * dz <= maximum * maximum;
}
private static (double X, double Y) GlobalXY(CreateObject.ServerPosition position)
{
uint landblockX = (position.LandblockId >> 24) & 0xFFu;
uint landblockY = (position.LandblockId >> 16) & 0xFFu;
return (
landblockX * 192.0 + position.PositionX,
landblockY * 192.0 + position.PositionY);
}
private static bool NonZero(uint? value) => value.GetValueOrDefault() != 0u;
}

View file

@ -99,7 +99,8 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif
/// <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.
/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the
/// retail 25-second leave-visibility lifecycle ends it.
/// </summary>
public sealed class LiveEntityRecord
{