acdream/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs
Erik 8d63e5c28a test(vfx): harden live missile and effect lifetimes
Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 15:47:00 +02:00

179 lines
6.4 KiB
C#

using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Projects Setup lights for live top-level and attached entities, while the
/// shared pose registry supplies their current root on each update frame.
/// </summary>
/// <remarks>
/// Retail <c>CPartArray::SetFrame</c> (<c>0x00519310</c>) calls
/// <c>LIGHTLIST::set_frame</c> (<c>0x00517C60</c>) whenever the owning physics
/// object's frame changes. Logical ownership remains in
/// <see cref="LiveEntityRuntime"/>; leaving the world removes only this
/// cell-scoped presentation and re-entry registers it again.
/// </remarks>
public sealed class LiveEntityLightController : IDisposable
{
private readonly LiveEntityRuntime _liveEntities;
private readonly EntityEffectPoseRegistry _poses;
private readonly LightingHookSink _lighting;
private readonly Func<uint, Setup?> _loadSetup;
private readonly Dictionary<uint, uint> _serverGuidByOwner = new();
private readonly HashSet<uint> _presentOwners = new();
public LiveEntityLightController(
LiveEntityRuntime liveEntities,
EntityEffectPoseRegistry poses,
LightingHookSink lighting,
Func<uint, Setup?> loadSetup)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
_loadSetup = loadSetup ?? throw new ArgumentNullException(nameof(loadSetup));
_lighting.OwnerLightingChanged += OnOwnerLightingChanged;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
public bool Register(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity
|| !record.IsSpatiallyProjected
|| !record.IsSpatiallyVisible)
{
return false;
}
_serverGuidByOwner[entity.Id] = serverGuid;
_presentOwners.Add(entity.Id);
_lighting.UnregisterOwner(entity.Id, forgetState: false);
_lighting.InitializeOwnerLighting(
entity.Id,
(record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) != 0x02000000u)
return false;
if (record.ProjectionKind is LiveEntityProjectionKind.World)
{
if (!_poses.UpdateRoot(entity))
_poses.PublishMeshRefs(entity);
}
Setup? setup = _loadSetup(entity.SourceGfxObjOrSetupId);
if (setup is null
|| setup.Lights.Count == 0
|| !_lighting.IsOwnerLightingEnabled(entity.Id))
return false;
bool isDynamic = (record.FinalPhysicsState & PhysicsStateFlags.Static) == 0;
IReadOnlyList<LightSource> loaded = LightInfoLoader.Load(
setup,
entity.Id,
entity.Position,
entity.Rotation,
isDynamic,
entity.ParentCellId ?? 0u,
tracksOwnerPose: true);
for (int i = 0; i < loaded.Count; i++)
_lighting.RegisterOwnedLight(loaded[i]);
return loaded.Count > 0;
}
/// <summary>Withdraw cell-scoped presentation but retain logical light state.</summary>
public void Unregister(uint ownerLocalId)
{
_presentOwners.Remove(ownerLocalId);
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
}
/// <summary>Apply a fresh authoritative PhysicsState Lighting transition.</summary>
public void OnStateChanged(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return;
}
_lighting.SetOwnerLighting(
entity.Id,
(record.FinalPhysicsState & PhysicsStateFlags.Lighting) != 0);
}
/// <summary>End logical ownership after leave-world presentation teardown.</summary>
public void Forget(uint ownerLocalId)
{
_presentOwners.Remove(ownerLocalId);
_serverGuidByOwner.Remove(ownerLocalId);
_lighting.UnregisterOwner(ownerLocalId);
}
public void Refresh() => _lighting.RefreshAttachedLights();
internal int TrackedOwnerCount => _serverGuidByOwner.Count;
internal int PresentedOwnerCount => _presentOwners.Count;
/// <summary>
/// Attached projection visibility can become true before its holding-part
/// composition is published. The attachment owner calls this barrier only
/// after the composed child root is current.
/// </summary>
public void OnAttachedPoseReady(uint serverGuid)
{
if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
|| record.ProjectionKind is not LiveEntityProjectionKind.Attached
|| record.WorldEntity is not { } entity
|| _presentOwners.Contains(entity.Id))
{
return;
}
Register(serverGuid);
}
private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled)
{
if (!_presentOwners.Contains(ownerLocalId)
|| !_serverGuidByOwner.TryGetValue(ownerLocalId, out uint serverGuid))
{
return;
}
if (!enabled)
{
_lighting.UnregisterOwner(ownerLocalId, forgetState: false);
return;
}
Register(serverGuid);
}
public void Dispose()
{
_lighting.OwnerLightingChanged -= OnOwnerLightingChanged;
_liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged;
foreach (uint ownerId in _serverGuidByOwner.Keys.ToArray())
Forget(ownerId);
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (record.WorldEntity is not { } entity)
return;
if (record.ProjectionKind is LiveEntityProjectionKind.Attached)
{
// The true edge precedes attachment pose publication. False is
// safe and must immediately withdraw the cell-scoped light.
if (!visible)
Unregister(entity.Id);
return;
}
if (visible)
Register(record.ServerGuid);
else
Unregister(entity.Id);
}
}