using AcDream.App.Physics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.App.World;
///
/// Applies the presentation/collision side effects of accepted retail
/// PhysicsState transitions after the canonical live owner is ready.
/// Logical ownership stays in ; Hidden never
/// unregisters scripts, particles, lights, meshes, or the live record.
///
///
/// Ports CPhysicsObj::set_state (0x00514DD0),
/// set_nodraw (0x0050FCA0), and set_hidden
/// (0x00514C60). Typed script ids are retail
/// PS_UnHide=0x75 and PS_Hidden=0x76 from acclient.h.
///
public sealed class LiveEntityPresentationController : IDisposable
{
public const uint UnHideScriptType = 0x75u;
public const uint HiddenScriptType = 0x76u;
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private readonly Func _playTyped;
private readonly Action _setDirectChildrenNoDraw;
private readonly Action _clearInvalidTarget;
private readonly Func<(int X, int Y)> _liveCenter;
private readonly Action? _onShadowRestored;
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
private readonly HashSet _readyOwners = [];
private readonly HashSet _suspendedShadowOwners = [];
private readonly HashSet _drainingRecords = new();
private bool _disposed;
public LiveEntityPresentationController(
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows,
Func playTyped,
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
Action? setDirectChildrenNoDraw = null,
Action? clearInvalidTarget = null,
Func<(int X, int Y)>? liveCenter = null,
Action? onShadowRestored = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_playTyped = playTyped ?? throw new ArgumentNullException(nameof(playTyped));
_partArrayEnterWorld = partArrayEnterWorld
?? throw new ArgumentNullException(nameof(partArrayEnterWorld));
_setDirectChildrenNoDraw = setDirectChildrenNoDraw ?? ((_, _) => { });
_clearInvalidTarget = clearInvalidTarget ?? (_ => { });
_liveCenter = liveCenter ?? (() => (0, 0));
_onShadowRestored = onShadowRestored;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
///
/// Opens the state-side-effect barrier after render/effect owners have
/// registered, then drains constructor and pre-materialization transitions
/// exactly once in accepted order.
///
public bool OnLiveEntityReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is null
|| !record.ResourcesRegistered)
{
return false;
}
RuntimeEntityKey key = RequireProjectionKey(record);
_readyOwners.Add(key);
if (!ApplyPendingTransitions(record)
|| record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
// An object can materialize into a pending landblock before collision
// registration and before this ready barrier opens. Its initial false
// visibility edge therefore had nothing to suspend. Reconcile here,
// after every create-time owner exists, so the retained registration
// cannot remain active offscreen and hydration has a restore marker.
SuspendOrdinaryShadowOutsideProjection(record, entity);
return IsCurrent(record, entity);
}
/// Drains a newly accepted SetState when this incarnation is ready.
public bool OnStateAccepted(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key))
{
return false;
}
return ApplyPendingTransitions(record);
}
internal bool HasDeferredShadowRestore(uint serverGuid) =>
TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key)
&& _suspendedShadowOwners.Contains(key);
internal int ReadyOwnerCount => _readyOwners.Count;
internal int DeferredShadowRestoreCount => _suspendedShadowOwners.Count;
public void Forget(LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (TryGetProjectionKey(record, out RuntimeEntityKey key))
{
_readyOwners.Remove(key);
_suspendedShadowOwners.Remove(key);
}
_drainingRecords.Remove(record);
}
public void Clear()
{
_readyOwners.Clear();
_suspendedShadowOwners.Clear();
_drainingRecords.Clear();
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
Clear();
}
private bool ApplyPendingTransitions(LiveEntityRecord record)
{
// State callbacks can synchronously accept another SetState. Retail's
// update thread completes the current transition before the next FIFO
// entry; a recursive drain would interleave Visible halfway through
// Hidden and then let the older side effects win last.
if (!_drainingRecords.Add(record))
return IsCurrent(record, record.WorldEntity);
try
{
while (record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition))
{
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return false;
}
// set_state writes the final state before any PartArray/cell side
// effect. Retained shadow registrations must see those same bits
// even while their cell rows are suspended.
_shadows.UpdatePhysicsState(entity.Id, (uint)transition.FinalState);
switch (transition.HiddenTransition)
{
case RetailHiddenTransition.BecameHidden:
_playTyped(entity.Id, HiddenScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, true);
if (!IsCurrent(record, entity))
return false;
_shadows.Suspend(entity.Id);
_suspendedShadowOwners.Add(RequireProjectionKey(record));
// Retail CPhysicsObj::set_hidden @ 0x00514C60 calls
// CPartArray::HandleEnterWorld after hiding the object
// from its cell. Despite the name, this is the motion
// timeline boundary: it strips link animations and
// aborts every pending completion through
// MotionTableManager::HandleEnterWorld @ 0x0051BDD0.
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
_clearInvalidTarget(record.ServerGuid);
if (!IsCurrent(record, entity))
return false;
break;
case RetailHiddenTransition.BecameVisible:
_playTyped(entity.Id, UnHideScriptType, 1f);
if (!IsCurrent(record, entity))
return false;
_setDirectChildrenNoDraw(record.ServerGuid, false);
if (!IsCurrent(record, entity))
return false;
// Retail invokes the same PartArray boundary before
// CObjCell::unhide_object restores cell visibility.
_partArrayEnterWorld.HandleEnterWorld(entity.Id);
if (!IsCurrent(record, entity))
return false;
bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return false;
if (restored)
_suspendedShadowOwners.Remove(RequireProjectionKey(record));
break;
}
}
return IsCurrent(record, record.WorldEntity);
}
finally
{
_drainingRecords.Remove(record);
}
}
private bool RestoreShadow(LiveEntityRecord record, AcDream.Core.World.WorldEntity entity)
{
if (!record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible
|| record.FullCellId == 0
// #319 F2 (contract §3.2): a committed child never owns an
// independent broadphase row (route 7's P4 record,
// RuntimeEntityDirectory.cs:451-465). IsSpatiallyProjected and
// IsSpatiallyVisible do NOT exclude a child - its presentation-
// only rebucket sets IsSpatiallyProjected=true every frame
// (LiveEntityRuntime.RebucketLiveEntityPresentationOnly) - so
// parentage must be checked directly, the same predicate
// LiveEntityHydrationController.OnLandblockLoaded's gate uses.
// CORRECTION (architecture review A4, 2026-08-05): the
// contract's §3.2 premise ("no-ops today on FullCellId == 0")
// holds only for PLAYER-parented children - route 7's D1
// already re-cells CREATURE-parented children to a nonzero
// cell, so this clause IS a live behavior change for that
// class (an NPC's wielded weapon no longer refreshes its
// shadow row on a Hidden->Visible edge). Right direction per
// route 7's P4 record; the connected gate's Half B must watch
// for it explicitly (issue #319 contract §7).
|| _liveEntities.ParentAttachments.HasCommittedParent(record.ServerGuid))
{
return false;
}
(int centerX, int centerY) = _liveCenter();
ShadowPositionSynchronizer.Sync(
_shadows,
entity.Id,
entity.Position,
entity.Rotation,
record.FullCellId,
centerX,
centerY);
_onShadowRestored?.Invoke(record.ServerGuid);
return true;
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.WorldEntity is not { } entity
|| !IsCurrent(record, entity))
{
return;
}
// exit_world removes an ordinary object's collision rows on the same
// projection edge that suspends its object clock. Keep the retained
// registration so a stationary object can be restored immediately on
// hydration; it may have no later movement quantum to repair itself.
// Projectiles own their matching suspend/restore transaction in their
// dedicated controller. C4 route 4b-3 deleted the standalone remote-
// teleport placement controller (and its `_activePlacementOwners`
// suspension gate here) — the canonical Runtime placement owner now
// handles that path, and this class no longer defers to a second
// authority.
if (!visible)
{
SuspendOrdinaryShadowOutsideProjection(record, entity);
return;
}
if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_suspendedShadowOwners.Contains(key))
{
return;
}
bool restored = RestoreShadow(record, entity);
if (!IsCurrent(record, entity))
return;
if (restored)
_suspendedShadowOwners.Remove(key);
}
private void SuspendOrdinaryShadowOutsideProjection(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity entity)
{
if (record.IsSpatiallyVisible
|| record.ProjectileRuntime is not null
|| !TryGetProjectionKey(record, out RuntimeEntityKey key)
|| !_readyOwners.Contains(key)
|| !_shadows.Suspend(entity.Id))
{
return;
}
_suspendedShadowOwners.Add(key);
}
private bool IsCurrent(
LiveEntityRecord record,
AcDream.Core.World.WorldEntity? entity) =>
entity is not null
&& _liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
&& ReferenceEquals(current, record)
&& ReferenceEquals(current.WorldEntity, entity);
private bool TryGetCurrentProjectionKey(
uint serverGuid,
out RuntimeEntityKey key)
{
if (_liveEntities.TryGetRecord(
serverGuid,
out LiveEntityRecord record))
{
return TryGetProjectionKey(record, out key);
}
key = default;
return false;
}
private static bool TryGetProjectionKey(
LiveEntityRecord record,
out RuntimeEntityKey key)
{
if (record.ProjectionKey is { } projectionKey)
{
key = projectionKey;
return true;
}
key = default;
return false;
}
private static RuntimeEntityKey RequireProjectionKey(
LiveEntityRecord record) =>
record.ProjectionKey
?? throw new InvalidOperationException(
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " +
"has no exact projection key.");
}