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

@ -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
{