using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
///
/// The exact owner is still inside a reference transition, so its requested
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
///
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
///
/// Routes server-spawned (CreateObject) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps). Shared
/// surfaces use the WB atlas while per-instance texture composites are owned
/// by the live entity and released with it.
///
///
/// Companion to : that adapter handles
/// atlas-tier (procedural) entities; this one handles per-instance-tier
/// (server-spawned). The boundary is ServerGuid != 0 on
/// .
///
///
///
/// Sequencer factory: the adapter is constructed with a
/// Func<WorldEntity, AnimationSequencer> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
/// Production callers supply a factory that fetches MotionTable from dats.
///
///
///
/// Adjustment 6 (resolved Adjustment 4): now
/// carries and
/// . applies
/// both to the created .
///
///
public sealed class EntitySpawnAdapter
{
private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// One logical owner per server GUID. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary _ownersByGuid = new();
private sealed class Owner(
WorldEntity entity,
AnimatedEntityState state,
HashSet meshIds)
{
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet MeshIds { get; set; } = meshIds;
public HashSet MeshReferencesHeld { get; } = new();
public bool IsPresentationResident { get; set; }
public bool TextureReleaseRequired { get; set; }
public PresentationTransition Transition { get; set; }
public bool RemovalPending { get; set; }
public bool HasPresentationResources
{
get
{
if (TextureReleaseRequired)
return true;
return MeshReferencesHeld.Count != 0;
}
}
public bool IsFullyResident
{
get
{
if (!IsPresentationResident || !TextureReleaseRequired)
return false;
return MeshReferencesHeld.SetEquals(MeshIds);
}
}
public bool IsFullySuspended =>
!IsPresentationResident && !HasPresentationResources;
}
private enum PresentationTransition
{
None,
Resuming,
Suspending,
ChangingAppearance,
}
///
/// Per-entity texture lifetime owner. Production uses
/// ; tests use a recording implementation.
///
///
/// Factory that builds an for a given
/// entity. Receives the full so it can look up
/// the Setup + MotionTable from the entity's SourceGfxObjOrSetupId
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
///
///
/// Optional WB mesh adapter. When non-null, presentation residency
/// registers each unique MeshRef.GfxObjId so WB background-loads
/// the mesh data. Projection suspension or logical removal balances those
/// references. When null, the adapter only tracks per-instance state.
///
public EntitySpawnAdapter(
IEntityTextureLifetime textureLifetime,
Func sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureLifetime);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureLifetime = textureLifetime;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
///
/// Process a server-spawned entity. Returns the created
/// for the entity, or null if
/// is atlas-tier (ServerGuid == 0).
///
public AnimatedEntityState? OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
// Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0)
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null —
// if the entity has no motion table the factory should construct a
// no-op sequencer (Setup + empty MotionTable + NullAnimationLoader).
var sequencer = _sequencerFactory(entity);
var state = new AnimatedEntityState(sequencer);
// Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
state.HideParts(entity.HiddenPartsMask);
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
HashSet meshIds = _meshAdapter is null
? []
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
// Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case
// the prior GUID incarnation and its presentation references remain
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
{
if (displacedOwner.RemovalPending)
{
throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
}
if (displacedOwner.Transition != PresentationTransition.None)
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
}
if (displacedOwner.HasPresentationResources)
{
if (!SuspendPresentation(displacedOwner))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
}
return state;
}
///
/// Changes only the shorter presentation/GPU lifetime of an already-created
/// live entity. A visible projection acquires WB mesh references; suspension
/// releases those references and every owner-scoped composite texture. The
/// animated state remains registered and no create-time scripts are replayed.
/// Duplicate edges and edges for an older incarnation of a reused server GUID
/// are ignored. A failed release keeps the published resident state and the
/// exact unfinished resources so the same edge can be retried safely.
///
/// true when this call applied a residency edge.
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
return false;
}
return resident
? ResumePresentation(owner)
: SuspendPresentation(owner);
}
///
/// Reconciles the mesh-reference set for an in-place retail
/// SmartBox::UpdateVisualDesc mutation. Added meshes are acquired
/// before makes the new appearance
/// visible. Only then is the exact new mesh set published and superseded
/// references released. A failed release remains represented by
/// and is retried by the next
/// residency edge or logical teardown.
/// runs after that commit point but
/// before retirement, so dependent pose/cache publication cannot leave the
/// new entity appearance paired with rolled-back mesh ownership.
///
///
/// The exact reference is the incarnation token.
/// A delayed appearance callback from a GUID-reused owner is ignored. The
/// method is render-thread only; a re-entrant request is rejected before
/// its publication callback can mutate the active owner.
///
///
/// true when the matching owner's appearance was published;
/// otherwise false for a stale, removing, or re-entrant owner.
///
public bool OnAppearanceChanged(
WorldEntity entity,
IReadOnlyList meshRefs,
IReadOnlyList partOverrides,
Action publishAppearance,
Action? afterPublication = null)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
return false;
}
HashSet nextMeshIds = _meshAdapter is null
? []
: CollectMeshIds(meshRefs, partOverrides);
owner.Transition = PresentationTransition.ChangingAppearance;
var acquiredThisTransition = new List();
bool meshSetPublished = false;
try
{
if (owner.IsPresentationResident && _meshAdapter is not null)
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
publishAppearance();
// Publication is the commit point: every desired resident mesh is
// owned before this assignment. Superseded references may remain
// temporarily held if their release reports a retryable failure,
// but they are no longer part of the published appearance set.
owner.MeshIds = nextMeshIds;
meshSetPublished = true;
afterPublication?.Invoke();
List? releaseFailures = owner.IsPresentationResident
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
: ReleaseAllMeshReferences(owner);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
{
// Acquisition failed before publication. Preserve the prior exact
// mesh set and undo only references acquired by this transition.
List? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireOrPublicationFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
///
/// Release the per-entity state for . Called
/// on RemoveObject. Unknown guids (never spawned, or already
/// removed) are silently ignored. Cleanup failure leaves the owner registered
/// and propagates the exception; a later call resumes its unfinished releases.
///
public void OnRemove(uint serverGuid)
=> _ = TryRemove(serverGuid, expectedEntity: null);
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
{
return false;
}
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
// so logical removal must not decrement or release a second time. Do
// not remove the dictionary entry until cleanup succeeds: otherwise a
// release exception would orphan its remaining references and make a
// later OnRemove retry impossible.
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
return true;
}
return false;
}
///
/// Releases a logical owner only when is the exact
/// incarnation currently registered for its server GUID. This is the live
/// runtime teardown entry point: a delayed callback from an older generation
/// cannot remove a replacement that reused the same GUID.
///
/// true when the matching owner was removed.
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
}
///
/// Look up the for a server guid.
/// Returns null if the entity was never spawned or has already
/// been removed.
///
public AnimatedEntityState? GetState(uint serverGuid)
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
private bool ResumePresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
owner.Transition = PresentationTransition.Resuming;
var acquiredThisTransition = new List();
bool desiredMeshSetAcquired = false;
try
{
if (_meshAdapter is not null)
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
desiredMeshSetAcquired = true;
owner.TextureReleaseRequired = true;
owner.IsPresentationResident = true;
List? releaseFailures = ReleaseMeshReferencesOutside(
owner,
owner.MeshIds);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
{
List? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
private bool SuspendPresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
// IsPresentationResident deliberately remains true until every release
// succeeds. Transition prevents a re-entrant duplicate edge from
// releasing the same resource twice, while the completion markers keep
// partial progress retryable after an exception.
owner.Transition = PresentationTransition.Suspending;
List? failures = null;
if (owner.TextureReleaseRequired)
{
try
{
_textureLifetime.ReleaseOwner(owner.Entity.Id);
owner.TextureReleaseRequired = false;
}
catch (Exception error)
{
(failures ??= new List()).Add(error);
}
}
List? meshFailures = ReleaseAllMeshReferences(owner);
if (meshFailures is not null)
(failures ??= new List()).AddRange(meshFailures);
if (failures is not null)
{
owner.Transition = PresentationTransition.None;
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
failures);
}
owner.IsPresentationResident = false;
owner.Transition = PresentationTransition.None;
return true;
}
private static HashSet CollectMeshIds(
IReadOnlyList meshRefs,
IReadOnlyList partOverrides)
{
var unique = new HashSet();
for (int i = 0; i < meshRefs.Count; i++)
unique.Add(meshRefs[i].GfxObjId);
for (int i = 0; i < partOverrides.Count; i++)
unique.Add(partOverrides[i].GfxObjId);
return unique;
}
private void AcquireMissingMeshReferences(
Owner owner,
HashSet desiredMeshIds,
List acquiredThisTransition)
{
if (_meshAdapter is null)
return;
foreach (ulong meshId in desiredMeshIds)
{
if (owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.IncrementRefCount(meshId);
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
throw;
}
}
}
private List? RollBackAcquiredMeshReferences(
Owner owner,
List acquiredThisTransition)
{
if (_meshAdapter is null)
return null;
List? failures = null;
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
{
ulong meshId = acquiredThisTransition[i];
if (!owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List()).Add(error);
}
}
return failures;
}
private List? ReleaseMeshReferencesOutside(
Owner owner,
HashSet desiredMeshIds)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
List? failures = null;
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
foreach (ulong meshId in heldSnapshot)
{
if (desiredMeshIds.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List()).Add(error);
}
}
return failures;
}
private List? ReleaseAllMeshReferences(Owner owner)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
return ReleaseMeshReferencesOutside(owner, []);
}
}