Owner G2 finding: the purple cloud around an arriving character no longer drew. The server keeps the player Hidden until acdream sends LoginComplete at reveal completion (retail-correct); the Hidden-state script's emitters spawn in the arrival cell and are view-eligible when the world appears, but the walk drew an owner's emitters only through the owner's registry rows, and a hidden owner's shadow is suspended. Retail's CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) gives an emitter one shadow in its OWN current cell, drawn at that cell's turn regardless of the parent's hidden state (add_shadows_to_cells 0x00514aed skips the flood for state & 0x1000). Port: ParticleSystem keeps a per-pass cell -> renderable-handles index (maintained at every renderable/OwnerCellId change) and CopyRenderableEmittersInCell; ParticleRenderer.DrawForCell; the walk draws particles BY CELL at the existing turns (interior CellParticles, landscape LandscapeCellParticles), the events fire for every visited cell, and every owner-union particle path is deleted (UnionOwners/UnionNewOwners for particles, the outdoor drawn-owner dedupe, the executor's owner classification sets, the context ParticleOwnerIds members). The post-replay per-cell pass double-submitted the root flood's emitters and is deleted: an emitter draws once, at its cell's replay turn. AD-117 item 4 becomes a port note (the index lives in the particle system; an emitter is not a physics object in acdream). The temporary [pes-spawn]/[pes-vis] traces are removed and the ACDREAM_DUMP_PLAYSCRIPT row restored. Verified: timed arrival route logs/selfgate-20260903-062522-haze-chunk6, frame h02-arrive-400ms shows the cloud at the character in Facility Hub. Gates (Release): Core 4,987/4,987; Content 214/214; Runtime 1,884/1,884; App hermetic lane 6,760/6,760; App InstalledDat 217 pass / 2 pre-existing #383. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
354 lines
14 KiB
C#
354 lines
14 KiB
C#
using AcDream.App.Physics;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime.Entities;
|
|
|
|
namespace AcDream.App.World;
|
|
|
|
/// <summary>
|
|
/// Applies the presentation/collision side effects of accepted retail
|
|
/// PhysicsState transitions after the canonical live owner is ready.
|
|
/// Logical ownership stays in <see cref="LiveEntityRuntime"/>; Hidden never
|
|
/// unregisters scripts, particles, lights, meshes, or the live record.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ports <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>),
|
|
/// <c>set_nodraw</c> (<c>0x0050FCA0</c>), and <c>set_hidden</c>
|
|
/// (<c>0x00514C60</c>). Typed script ids are retail
|
|
/// <c>PS_UnHide=0x75</c> and <c>PS_Hidden=0x76</c> from <c>acclient.h</c>.
|
|
/// </remarks>
|
|
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<uint, uint, float, bool> _playTyped;
|
|
private readonly Action<uint, bool> _setDirectChildrenNoDraw;
|
|
private readonly Action<uint> _clearInvalidTarget;
|
|
private readonly Func<(int X, int Y)> _liveCenter;
|
|
private readonly Action<uint>? _onShadowRestored;
|
|
private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld;
|
|
private readonly HashSet<RuntimeEntityKey> _readyOwners = [];
|
|
private readonly HashSet<RuntimeEntityKey> _suspendedShadowOwners = [];
|
|
private readonly HashSet<LiveEntityRecord> _drainingRecords = new();
|
|
private bool _disposed;
|
|
|
|
public LiveEntityPresentationController(
|
|
LiveEntityRuntime liveEntities,
|
|
ShadowObjectRegistry shadows,
|
|
Func<uint, uint, float, bool> playTyped,
|
|
LiveEntityPartArrayEnterWorldPort partArrayEnterWorld,
|
|
Action<uint, bool>? setDirectChildrenNoDraw = null,
|
|
Action<uint>? clearInvalidTarget = null,
|
|
Func<(int X, int Y)>? liveCenter = null,
|
|
Action<uint>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the state-side-effect barrier after render/effect owners have
|
|
/// registered, then drains constructor and pre-materialization transitions
|
|
/// exactly once in accepted order.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Drains a newly accepted SetState when this incarnation is ready.</summary>
|
|
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.");
|
|
}
|