Fixes #282 (plan S2). Adds register row AP-133. Retail gives a CPhysicsObj exactly ONE cell: ShouldDrawParticles @0x0050fe60 reads this->cell and calls IsInView on it, and set_cell_id @0x0050f4f0 / change_cell @0x00513390 are the only things that move it. acdream splits that into ParentCellId (render parent, deliberately null for outdoor dat stabs) and EffectCellId (the authored landcell those parentless stabs still need) - an adaptation, now recorded as AP-133. WorldEntity.EffectCellId documents itself as the stab field, with live and interior entities using ParentCellId.f24532adbegan writing it for live entities too. Because EntityEffectPoseRegistry resolved EffectCellId FIRST, that write won - and the audit shows only 3 of 14 cell writers maintain it. The other 11 do not, including the hottest paths: RemotePhysicsUpdater:239,294 and LiveEntityOrdinaryPhysicsUpdater:107 write ParentCellId every physics tick from the snapshot, and LocalPlayerProjectionController:79 writes the local player's cell every frame. So a moving entity updated its cell constantly while EffectCellId stayed frozen at whatever cell it materialized in. Its particles and lights kept being tested against that stale cell and failed IsInView the moment it crossed a boundary - effects vanishing on a monster that is plainly visible, or drawing through a wall from a room the viewer cannot see. The consumers had also drifted into disagreeing: EntityEffectPoseRegistry preferred EffectCellId while WbDrawDispatcher.TryGetEntityCell and the remote spawn seed preferred ParentCellId - two answers to "which cell is this in". - WorldEntity.VisibilityCellId (ParentCellId ?? EffectCellId) is the single accessor; all five consumer sites resolve through it, so the precedence cannot drift apart again. - LiveEntityRuntime's three live-entity EffectCellId writes are removed, restoring the field to its documented purpose. Its real writers - LandblockLoader:80,97 and LandblockBuildFactory:408 - are untouched, and the parentless-stab path is pinned by a new test. - f24532ad's actual fix is preserved: RebucketLiveEntity still installs the committed cell, just on the one field live entities use. LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell is back to moving the entity by ParentCellId alone - its original pre-f24532ad form - and passes. CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell had its two EffectCellId assertions (added byf24532ad, encoding the defect) replaced with the corrected contract: ParentCellId set, EffectCellId null, VisibilityCellId resolving - a stronger assertion, not a relaxed one. Complete Release solution: 10,836 passed / 4 skipped / 0 failed. User visual check still outstanding: a monster with an active spell effect crossing a cell boundary, and a lit static object, indoors and outdoors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
11 KiB
C#
327 lines
11 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Rendering.Vfx;
|
|
|
|
/// <summary>
|
|
/// Update-thread registry of final root and indexed part poses for particles,
|
|
/// lights, and other DAT-driven entity effects.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail composes particle anchors from the current physics-object/part frame
|
|
/// in <c>Particle::Init</c> (<c>0x0051C930</c>) and refreshes parent-local
|
|
/// particles from those frames in <c>ParticleEmitter::UpdateParticles</c>
|
|
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
|
|
/// those same final frames without coupling Core effects to the renderer.
|
|
/// </remarks>
|
|
public sealed class EntityEffectPoseRegistry :
|
|
IEntityEffectPoseSource,
|
|
IEntityEffectCellSource,
|
|
IEntityEffectPoseChangeSource,
|
|
IEntityEffectPoseLifetimeSource
|
|
{
|
|
private sealed class PoseRecord
|
|
{
|
|
public Matrix4x4 RootWorld;
|
|
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
|
|
public bool[] PartAvailable = Array.Empty<bool>();
|
|
public uint CellId;
|
|
public ulong LifetimeVersion;
|
|
public ulong ChangeVersion;
|
|
}
|
|
|
|
private readonly Dictionary<uint, PoseRecord> _poses = new();
|
|
private ulong _nextLifetimeVersion;
|
|
|
|
public event Action<uint>? EffectPoseChanged;
|
|
|
|
public int Count => _poses.Count;
|
|
|
|
public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal)
|
|
{
|
|
Publish(entity, partLocal, availability: null);
|
|
}
|
|
|
|
public void Publish(
|
|
WorldEntity entity,
|
|
IReadOnlyList<Matrix4x4> partLocal,
|
|
IReadOnlyList<bool>? availability)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
Publish(
|
|
entity.Id,
|
|
Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
|
* Matrix4x4.CreateTranslation(entity.Position),
|
|
partLocal,
|
|
entity.VisibilityCellId ?? 0u,
|
|
availability);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publish the exact indexed part transforms already installed on an
|
|
/// entity. Appearance replacement uses this overload so a PhysicsScript
|
|
/// delivered later in the same update observes the new parts immediately.
|
|
/// </summary>
|
|
public void PublishMeshRefs(WorldEntity entity)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
|
* Matrix4x4.CreateTranslation(entity.Position);
|
|
|
|
bool changed;
|
|
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
|
{
|
|
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
|
_poses.Add(entity.Id, record);
|
|
changed = true;
|
|
}
|
|
else
|
|
{
|
|
changed = record.RootWorld != rootWorld
|
|
|| record.CellId != (entity.VisibilityCellId ?? 0u);
|
|
}
|
|
|
|
record.RootWorld = rootWorld;
|
|
record.CellId = entity.VisibilityCellId ?? 0u;
|
|
if (entity.IndexedPartTransforms.Count > 0)
|
|
{
|
|
changed |= CopyParts(
|
|
record,
|
|
entity.IndexedPartTransforms,
|
|
entity.IndexedPartAvailable);
|
|
}
|
|
else
|
|
{
|
|
if (record.PartLocal.Length != entity.MeshRefs.Count)
|
|
{
|
|
record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
|
|
changed = true;
|
|
}
|
|
if (record.PartAvailable.Length != entity.MeshRefs.Count)
|
|
{
|
|
record.PartAvailable = new bool[entity.MeshRefs.Count];
|
|
changed = true;
|
|
}
|
|
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
|
{
|
|
changed |= record.PartLocal[i] != entity.MeshRefs[i].PartTransform
|
|
|| !record.PartAvailable[i];
|
|
record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
|
|
record.PartAvailable[i] = true;
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
record.ChangeVersion++;
|
|
EffectPoseChanged?.Invoke(entity.Id);
|
|
}
|
|
}
|
|
|
|
public void Publish(
|
|
uint localEntityId,
|
|
Matrix4x4 rootWorld,
|
|
IReadOnlyList<Matrix4x4> partLocal,
|
|
uint cellId,
|
|
IReadOnlyList<bool>? availability = null)
|
|
{
|
|
if (localEntityId == 0)
|
|
return;
|
|
ArgumentNullException.ThrowIfNull(partLocal);
|
|
|
|
bool changed;
|
|
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
|
{
|
|
record = new PoseRecord { LifetimeVersion = NextLifetimeVersion() };
|
|
_poses.Add(localEntityId, record);
|
|
changed = true;
|
|
}
|
|
else
|
|
{
|
|
changed = record.RootWorld != rootWorld || record.CellId != cellId;
|
|
}
|
|
|
|
record.RootWorld = rootWorld;
|
|
record.CellId = cellId;
|
|
changed |= CopyParts(record, partLocal, availability);
|
|
if (changed)
|
|
{
|
|
record.ChangeVersion++;
|
|
EffectPoseChanged?.Invoke(localEntityId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Refresh only the moving root while retaining current part poses.</summary>
|
|
public bool UpdateRoot(WorldEntity entity)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entity);
|
|
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
|
|
return false;
|
|
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
|
|
* Matrix4x4.CreateTranslation(entity.Position);
|
|
// #282: resolve through the one owner. Reading EffectCellId first was
|
|
// inverted relative to every other consumer
|
|
// (WbDrawDispatcher.TryGetEntityCell,
|
|
// LiveEntityNetworkUpdateController's spawn seed), and for a live
|
|
// entity it pinned effects to the materialization cell - only 3 of 14
|
|
// cell writers maintain EffectCellId, while ParentCellId is updated
|
|
// every physics tick.
|
|
uint cellId = entity.VisibilityCellId ?? 0u;
|
|
if (record.RootWorld == rootWorld && record.CellId == cellId)
|
|
return true;
|
|
|
|
record.RootWorld = rootWorld;
|
|
record.CellId = cellId;
|
|
record.ChangeVersion++;
|
|
EffectPoseChanged?.Invoke(entity.Id);
|
|
return true;
|
|
}
|
|
|
|
public bool Remove(uint localEntityId)
|
|
{
|
|
if (!_poses.Remove(localEntityId))
|
|
return false;
|
|
EffectPoseChanged?.Invoke(localEntityId);
|
|
return true;
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
if (_poses.Count == 0)
|
|
return;
|
|
|
|
uint[] removedOwners = _poses.Keys.ToArray();
|
|
_poses.Clear();
|
|
foreach (uint owner in removedOwners)
|
|
{
|
|
EffectPoseChanged?.Invoke(owner);
|
|
}
|
|
}
|
|
|
|
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
|
{
|
|
rootWorld = record.RootWorld;
|
|
return true;
|
|
}
|
|
rootWorld = default;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal)
|
|
{
|
|
if (partIndex >= 0
|
|
&& _poses.TryGetValue(localEntityId, out PoseRecord? record)
|
|
&& partIndex < record.PartLocal.Length
|
|
&& record.PartAvailable[partIndex])
|
|
{
|
|
partLocal = record.PartLocal[partIndex];
|
|
return true;
|
|
}
|
|
partLocal = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Borrow the current indexed part-pose snapshot for synchronous
|
|
/// update-thread composition. Callers must not retain or mutate it.
|
|
/// </summary>
|
|
public bool TryGetPartPoses(
|
|
uint localEntityId,
|
|
out IReadOnlyList<Matrix4x4> partLocal)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
|
{
|
|
partLocal = record.PartLocal;
|
|
return true;
|
|
}
|
|
partLocal = Array.Empty<Matrix4x4>();
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetPartPoseSnapshot(
|
|
uint localEntityId,
|
|
out IReadOnlyList<Matrix4x4> partLocal,
|
|
out IReadOnlyList<bool> availability)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
|
{
|
|
partLocal = record.PartLocal;
|
|
availability = record.PartAvailable;
|
|
return true;
|
|
}
|
|
partLocal = Array.Empty<Matrix4x4>();
|
|
availability = Array.Empty<bool>();
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetCellId(uint localEntityId, out uint cellId)
|
|
{
|
|
if (_poses.TryGetValue(localEntityId, out PoseRecord? record))
|
|
{
|
|
cellId = record.CellId;
|
|
return true;
|
|
}
|
|
cellId = 0;
|
|
return false;
|
|
}
|
|
|
|
public ulong GetPoseOwnerLifetimeVersion(uint localEntityId) =>
|
|
_poses.TryGetValue(localEntityId, out PoseRecord? record)
|
|
? record.LifetimeVersion
|
|
: 0UL;
|
|
|
|
/// <summary>
|
|
/// Monotonic version of the current lifetime's root, part, availability,
|
|
/// or cell pose. Synchronous presentation owners use it to avoid repeating
|
|
/// a derived composition when the published parent pose is unchanged.
|
|
/// </summary>
|
|
public ulong GetPoseChangeVersion(uint localEntityId) =>
|
|
_poses.TryGetValue(localEntityId, out PoseRecord? record)
|
|
? record.ChangeVersion
|
|
: 0UL;
|
|
|
|
private ulong NextLifetimeVersion()
|
|
{
|
|
_nextLifetimeVersion++;
|
|
if (_nextLifetimeVersion == 0UL)
|
|
_nextLifetimeVersion++;
|
|
return _nextLifetimeVersion;
|
|
}
|
|
|
|
private static bool CopyParts(
|
|
PoseRecord record,
|
|
IReadOnlyList<Matrix4x4> partLocal,
|
|
IReadOnlyList<bool>? availability)
|
|
{
|
|
if (availability is not null && availability.Count != partLocal.Count)
|
|
throw new ArgumentException("Part pose and availability counts must match.");
|
|
bool changed = false;
|
|
if (record.PartLocal.Length != partLocal.Count)
|
|
{
|
|
record.PartLocal = new Matrix4x4[partLocal.Count];
|
|
changed = true;
|
|
}
|
|
if (record.PartAvailable.Length != partLocal.Count)
|
|
{
|
|
record.PartAvailable = new bool[partLocal.Count];
|
|
changed = true;
|
|
}
|
|
for (int i = 0; i < partLocal.Count; i++)
|
|
{
|
|
bool partAvailable = availability is null || availability[i];
|
|
changed |= record.PartLocal[i] != partLocal[i]
|
|
|| record.PartAvailable[i] != partAvailable;
|
|
record.PartLocal[i] = partLocal[i];
|
|
record.PartAvailable[i] = partAvailable;
|
|
}
|
|
return changed;
|
|
}
|
|
}
|
|
|
|
public interface IEntityEffectPoseLifetimeSource
|
|
{
|
|
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
|
|
}
|