feat(vfx): bind effects to live animated poses

This commit is contained in:
Erik 2026-07-14 10:56:01 +02:00
parent 96ddfdf175
commit 542dcfc384
41 changed files with 3246 additions and 741 deletions

View file

@ -0,0 +1,132 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Reusable parent-before-child traversal for retail's
/// <c>CPhysicsObj::UpdateChildrenInternal</c> attachment graph. Failed parent
/// projections suppress their descendants for the same frame; callers remove
/// those projections only after traversal, so dictionary enumeration remains
/// stable.
/// </summary>
internal sealed class AttachmentUpdateOrder<TChild>
{
private readonly HashSet<uint> _visiting = new();
private readonly HashSet<uint> _completed = new();
private readonly HashSet<uint> _failedSet = new();
private readonly List<uint> _failed = new();
private readonly List<uint> _subtree = new();
private readonly Queue<uint> _recoveryQueue = new();
private readonly HashSet<uint> _recoveryVisited = new();
/// <summary>
/// Updates the current attachment map without per-frame delegate or
/// enumerator boxing. The returned list is borrowed until the next call.
/// </summary>
public IReadOnlyList<uint> ForEachParentFirst(
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> update)
{
ArgumentNullException.ThrowIfNull(children);
ArgumentNullException.ThrowIfNull(parentOf);
ArgumentNullException.ThrowIfNull(update);
_visiting.Clear();
_completed.Clear();
_failedSet.Clear();
_failed.Clear();
foreach (uint childId in children.Keys)
Visit(childId, children, parentOf, update);
return _failed;
}
/// <summary>
/// Returns descendants before ancestors so an attachment subtree can be
/// withdrawn without leaving a child rooted at a stale intermediate pose.
/// The returned list is borrowed until the next call.
/// </summary>
public IReadOnlyList<uint> CollectSubtreePostOrder(
Dictionary<uint, TChild> children,
uint rootId,
Func<TChild, uint?> parentOf)
{
_subtree.Clear();
_visiting.Clear();
Collect(rootId, children, parentOf);
return _subtree;
}
/// <summary>
/// Retries every waiting child after a parent pose becomes available.
/// Each successfully realized child becomes a parent candidate in turn,
/// so A→B→C recovers in one parent-first drain.
/// </summary>
public void RealizeDescendants(
uint parentId,
Func<uint, IReadOnlyList<uint>> childrenWaitingForParent,
Func<uint, bool> realize)
{
ArgumentNullException.ThrowIfNull(childrenWaitingForParent);
ArgumentNullException.ThrowIfNull(realize);
_recoveryQueue.Clear();
_recoveryVisited.Clear();
_recoveryQueue.Enqueue(parentId);
_recoveryVisited.Add(parentId);
while (_recoveryQueue.Count > 0)
{
uint currentParent = _recoveryQueue.Dequeue();
IReadOnlyList<uint> waiting = childrenWaitingForParent(currentParent);
for (int i = 0; i < waiting.Count; i++)
{
uint childId = waiting[i];
if (realize(childId) && _recoveryVisited.Add(childId))
_recoveryQueue.Enqueue(childId);
}
}
}
private void Collect(
uint rootId,
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf)
{
if (!_visiting.Add(rootId))
return;
foreach ((uint candidateId, TChild child) in children)
{
if (parentOf(child) == rootId)
Collect(candidateId, children, parentOf);
}
_subtree.Add(rootId);
}
private void Visit(
uint childId,
Dictionary<uint, TChild> children,
Func<TChild, uint?> parentOf,
Func<uint, bool> update)
{
if (_completed.Contains(childId)
|| !children.TryGetValue(childId, out TChild? child))
{
return;
}
if (!_visiting.Add(childId))
return;
uint? parentId = parentOf(child);
if (parentId is { } attachedParentId && children.ContainsKey(attachedParentId))
Visit(attachedParentId, children, parentOf, update);
_visiting.Remove(childId);
if (!_completed.Add(childId))
return;
bool succeeded = parentId is not { } parent
|| !_failedSet.Contains(parent);
if (succeeded)
succeeded = update(childId);
if (!succeeded && _failedSet.Add(childId))
_failed.Add(childId);
}
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Items;
using AcDream.Core.Meshing;
using AcDream.Core.Net;
@ -27,12 +28,20 @@ public sealed class EquippedChildRenderController : IDisposable
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities;
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
private readonly EntityEffectPoseRegistry _poses;
private ParentAttachmentState Relations => _liveEntities.ParentAttachments;
/// <summary>Raised after the attached projection is fully registered.</summary>
public event Action<uint>? EntityReady;
/// <summary>Raised after an attached projection has its composed pose.</summary>
public event Action<uint>? ProjectionPoseReady;
/// <summary>Raised when an attached projection leaves its cell presentation.</summary>
public event Action<uint>? ProjectionRemoved;
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly AttachmentUpdateOrder<AttachedChild> _updateOrder = new();
private readonly Func<AttachedChild, uint?> _parentOfAttached;
private readonly Func<uint, bool> _tickAttached;
public IEnumerable<uint> AttachedEntityIds
{
@ -48,13 +57,17 @@ public sealed class EquippedChildRenderController : IDisposable
object datLock,
ClientObjectTable objects,
LiveEntityRuntime liveEntities,
EntityEffectPoseRegistry poses,
Func<ParentEvent.Parsed, bool> acceptParent)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
_parentOfAttached = static child => child.ParentGuid;
_tickAttached = TickChild;
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRolledBack += OnMoveRolledBack;
@ -83,17 +96,11 @@ public sealed class EquippedChildRenderController : IDisposable
spawn.PositionSequence));
}
ResolveRelations(spawn.Guid);
TryRealize(spawn.Guid);
ResolveAndTryRealize(spawn.Guid);
// ParentEvent can precede the parent's CreateObject. Revisit every
// child waiting specifically on the object that just arrived.
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(spawn.Guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
// ParentEvent can precede the parent's CreateObject. Revisit the
// complete descendant chain rooted at the object that arrived.
RetryWaitingDescendants(spawn.Guid);
}
}
@ -107,15 +114,20 @@ public sealed class EquippedChildRenderController : IDisposable
{
lock (_datLock)
{
IReadOnlyList<uint> waiting = Relations.ChildrenWaitingForParent(guid);
for (int i = 0; i < waiting.Count; i++)
{
ResolveRelations(waiting[i]);
TryRealize(waiting[i]);
}
RetryWaitingDescendants(guid);
}
}
/// <summary>
/// Retries pose-withdrawn descendants only after their parent's new root
/// and indexed parts have been published.
/// </summary>
public void OnPosePublished(uint guid)
{
lock (_datLock)
RetryWaitingDescendants(guid);
}
/// <summary>
/// Apply/queue a live ParentEvent after the live-entity owner has exact-
/// gated the parent's INSTANCE_TS and advanced the child's shared
@ -126,8 +138,8 @@ public sealed class EquippedChildRenderController : IDisposable
lock (_datLock)
{
Relations.Enqueue(update);
ResolveRelations(update.ChildGuid);
TryRealize(update.ChildGuid);
if (ResolveAndTryRealize(update.ChildGuid))
RetryWaitingDescendants(update.ChildGuid);
}
}
@ -171,68 +183,103 @@ public sealed class EquippedChildRenderController : IDisposable
/// <summary>Recompose every child after the parent's animation tick.</summary>
public void Tick()
{
foreach (AttachedChild child in _attachedByChild.Values)
{
if (!_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent))
continue;
if (!EquippedChildAttachment.TryCompose(
child.ParentSetup,
parent.MeshRefs,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
out IReadOnlyList<MeshRef> parts))
continue;
child.Entity.MeshRefs = parts;
child.Entity.SetPosition(parent.Position);
child.Entity.Rotation = parent.Rotation;
child.Entity.ParentCellId = parent.ParentCellId;
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
}
IReadOnlyList<uint> failed = _updateOrder.ForEachParentFirst(
_attachedByChild,
_parentOfAttached,
_tickAttached);
for (int i = 0; i < failed.Count; i++)
WithdrawForPoseLoss(failed[i]);
}
private void TryRealize(uint childGuid)
private bool TickChild(uint childGuid)
{
if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child))
return false;
if (_liveEntities.TryGetWorldEntity(child.ParentGuid, out WorldEntity parent)
&& _poses.TryGetRootPose(parent.Id, out Matrix4x4 parentWorld)
&& _poses.TryGetPartPoseSnapshot(
parent.Id,
out var parentPartPoses,
out var parentPartAvailability)
&& EquippedChildAttachment.TryComposePoseInto(
child.ParentSetup,
parentPartPoses,
parentPartAvailability,
child.ChildSetup,
child.ParentLocation,
child.Placement,
child.PartTemplate,
child.Scale,
child.PartPoseBuffer,
child.AttachedPartBuffer,
out EquippedChildPose pose))
{
child.Entity.MeshRefs = pose.AttachedParts;
child.Entity.SetIndexedPartPoses(pose.PartLocal, child.PartAvailability);
if (!ApplyParentWorldPose(child.Entity, parentWorld))
return false;
child.Entity.ParentCellId = parent.ParentCellId;
PublishChildPose(child.Entity, parentWorld, parent.ParentCellId, pose);
if (parent.ParentCellId is { } parentCellId)
_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
ProjectionPoseReady?.Invoke(child.ChildGuid);
return true;
}
return false;
}
private bool TryRealize(uint childGuid)
{
if (!Relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
return;
return false;
if (!_liveEntities.TryGetWorldEntity(pending.ParentGuid, out WorldEntity parentEntity)
|| !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn)
|| !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn))
return;
return false;
if (parentSpawn.SetupTableId is not { } parentSetupId
|| childSpawn.SetupTableId is not { } childSetupId
|| parentEntity.ParentCellId is not { } parentCellId)
return;
return false;
Setup? parentSetup = _dats.Get<Setup>(parentSetupId);
Setup? childSetup = _dats.Get<Setup>(childSetupId);
if (parentSetup is null || childSetup is null)
return;
return false;
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> template = BuildPartTemplate(childSetup, childSpawn);
bool[] childPartAvailability = BuildPartAvailability(template);
float scale = childSpawn.ObjScale is { } objScale && objScale > 0f
? objScale
: 1.0f;
if (!_poses.TryGetPartPoseSnapshot(
parentEntity.Id,
out var parentPartPoses,
out var parentPartAvailability))
return false;
if (!_poses.TryGetRootPose(parentEntity.Id, out Matrix4x4 parentWorld)
|| !TryDecomposeWorldPose(parentWorld, out Vector3 parentPosition, out Quaternion parentRotation))
{
return false;
}
if (!EquippedChildAttachment.TryCompose(
if (!EquippedChildAttachment.TryComposePoseInto(
parentSetup,
parentEntity.MeshRefs,
parentPartPoses,
parentPartAvailability,
childSetup,
parentLocation,
placement,
template,
scale,
out IReadOnlyList<MeshRef> parts))
return;
partPoseBuffer: null,
attachedPartBuffer: null,
out EquippedChildPose pose))
return false;
// A parented object may materialize here before the ordinary world
// hydration path sees it. Install its logical effect profile before
@ -250,29 +297,34 @@ public sealed class EquippedChildRenderController : IDisposable
WorldEntity? entity = _liveEntities.MaterializeLiveEntity(
childGuid,
parentCellId,
localId => new WorldEntity
localId =>
{
Id = localId,
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentEntity.Position,
Rotation = parentEntity.Rotation,
MeshRefs = parts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
var created = new WorldEntity
{
Id = localId,
ServerGuid = childGuid,
SourceGfxObjOrSetupId = childSetupId,
Position = parentPosition,
Rotation = parentRotation,
MeshRefs = pose.AttachedParts,
PaletteOverride = BuildPaletteOverride(childSpawn),
ParentCellId = parentCellId,
};
created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
return created;
},
LiveEntityProjectionKind.Attached);
if (entity is null)
return;
entity.SetPosition(parentEntity.Position);
entity.Rotation = parentEntity.Rotation;
return false;
ApplyParentWorldPose(entity, parentWorld);
entity.ParentCellId = parentCellId;
entity.ApplyAppearance(
parts,
pose.AttachedParts,
BuildPaletteOverride(childSpawn),
childSpawn.AnimPartChanges is { Count: > 0 } changes
? changes.Select(change => new PartOverride(change.PartIndex, change.NewModelId)).ToArray()
: Array.Empty<PartOverride>());
entity.SetIndexedPartPoses(pose.PartLocal, childPartAvailability);
_attachedByChild[childGuid] = new AttachedChild(
pending.ParentGuid,
childGuid,
@ -281,13 +333,60 @@ public sealed class EquippedChildRenderController : IDisposable
parentSetup,
childSetup,
template,
childPartAvailability,
pose.PartLocal,
pose.AttachedParts,
scale,
entity);
PublishChildPose(entity, parentWorld, parentEntity.ParentCellId, pose);
Console.WriteLine(
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
$"location={parentLocation} placement={placement}");
Relations.MarkProjected(childGuid);
ProjectionPoseReady?.Invoke(childGuid);
EntityReady?.Invoke(childGuid);
return true;
}
private void PublishChildPose(
WorldEntity child,
Matrix4x4 parentWorld,
uint? parentCellId,
EquippedChildPose pose)
{
_poses.Publish(
child.Id,
pose.RootLocal * parentWorld,
pose.PartLocal,
parentCellId ?? 0u,
child.IndexedPartAvailable);
}
/// <summary>
/// Installs the immediate parent's composed world root on the child
/// projection. The child's MeshRefs already contain its holding and
/// placement transforms relative to that direct parent root.
/// </summary>
internal static bool ApplyParentWorldPose(WorldEntity child, Matrix4x4 parentWorld)
{
if (!TryDecomposeWorldPose(parentWorld, out Vector3 position, out Quaternion rotation))
return false;
child.SetPosition(position);
child.Rotation = rotation;
return true;
}
private static bool TryDecomposeWorldPose(
Matrix4x4 world,
out Vector3 position,
out Quaternion rotation)
{
position = world.Translation;
if (!Matrix4x4.Decompose(world, out Vector3 scale, out rotation, out _)
|| Vector3.DistanceSquared(scale, Vector3.One) > 1e-6f)
return false;
rotation = Quaternion.Normalize(rotation);
return true;
}
/// <summary>
@ -337,6 +436,20 @@ public sealed class EquippedChildRenderController : IDisposable
_acceptParent);
}
private bool ResolveAndTryRealize(uint childGuid)
{
ResolveRelations(childGuid);
return TryRealize(childGuid);
}
private void RetryWaitingDescendants(uint parentGuid)
{
_updateOrder.RealizeDescendants(
parentGuid,
Relations.ChildrenWaitingForParent,
ResolveAndTryRealize);
}
private IReadOnlyList<MeshRef> BuildPartTemplate(
Setup setup,
WorldSession.EntitySpawn spawn)
@ -396,6 +509,14 @@ public sealed class EquippedChildRenderController : IDisposable
return result;
}
private bool[] BuildPartAvailability(IReadOnlyList<MeshRef> template)
{
var available = new bool[template.Count];
for (int i = 0; i < template.Count; i++)
available[i] = _dats.Get<GfxObj>(template[i].GfxObjId) is not null;
return available;
}
private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
{
if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
@ -430,27 +551,52 @@ public sealed class EquippedChildRenderController : IDisposable
if (Relations.RestoreLastAccepted(item.ObjectId))
{
lock (_datLock)
TryRealize(item.ObjectId);
{
if (ResolveAndTryRealize(item.ObjectId))
RetryWaitingDescendants(item.ObjectId);
}
}
}
private void Remove(uint childGuid)
{
if (!_attachedByChild.Remove(childGuid))
if (!_attachedByChild.Remove(childGuid, out AttachedChild? child))
return;
_liveEntities.WithdrawLiveEntityProjection(childGuid);
_poses.Remove(child.Entity.Id);
ProjectionRemoved?.Invoke(child.Entity.Id);
}
private void WithdrawForPoseLoss(uint childGuid)
{
if (!_attachedByChild.ContainsKey(childGuid))
return;
Remove(childGuid);
// MarkProjected removed the active candidate but retained the last
// accepted relationship. Requeue that exact accepted relation so a
// later parent pose/appearance registration can realize it again.
Relations.RestoreLastAccepted(childGuid);
}
private void TearDownObjectProjections(uint guid)
{
Remove(guid);
uint[] children = _attachedByChild.Values
.Where(child => child.ParentGuid == guid)
.Select(child => child.ChildGuid)
.ToArray();
for (int i = 0; i < children.Length; i++)
Remove(children[i]);
IReadOnlyList<uint> subtree = _updateOrder.CollectSubtreePostOrder(
_attachedByChild,
guid,
_parentOfAttached);
for (int i = 0; i < subtree.Count; i++)
{
uint childGuid = subtree[i];
Remove(childGuid);
if (childGuid != guid)
{
// Retail unparents direct children. Descendant relationships
// can become renderable again if their immediate parent later
// acquires a top-level Position, so retain their accepted
// relationship as a pending projection candidate.
Relations.RestoreLastAccepted(childGuid);
}
}
}
public void Clear()
@ -477,6 +623,9 @@ public sealed class EquippedChildRenderController : IDisposable
Setup ParentSetup,
Setup ChildSetup,
IReadOnlyList<MeshRef> PartTemplate,
IReadOnlyList<bool> PartAvailability,
Matrix4x4[] PartPoseBuffer,
MeshRef[] AttachedPartBuffer,
float Scale,
WorldEntity Entity);
}

View file

@ -286,7 +286,8 @@ public sealed class GameWindow : IDisposable
/// from the current animation frame; only these two fields are
/// reused unchanged.
/// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public required IReadOnlyList<AnimatedPartTemplate> PartTemplate;
public required IReadOnlyList<bool> PartAvailability;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
@ -307,8 +308,15 @@ public sealed class GameWindow : IDisposable
/// in-place mutation of this list.
/// </summary>
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
/// <summary>Indexed final part poses, including debug-hidden parts.</summary>
public readonly List<System.Numerics.Matrix4x4> EffectPartPosesScratch = new();
}
internal readonly record struct AnimatedPartTemplate(
uint GfxObjId,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides,
bool IsDrawable);
private sealed record AppearanceUpdateState(
AcDream.Core.World.WorldEntity Entity,
AnimatedEntity? Animation);
@ -332,6 +340,8 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
@ -815,6 +825,7 @@ public sealed class GameWindow : IDisposable
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
// #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
@ -1444,7 +1455,12 @@ public sealed class GameWindow : IDisposable
// StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem);
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
_particleSink.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
_hookRouter,
_effectPoses);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
@ -1460,7 +1476,7 @@ public sealed class GameWindow : IDisposable
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g.
@ -2294,8 +2310,10 @@ public sealed class GameWindow : IDisposable
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// transforms via SetupPartTransforms.Compute so multi-emitter scripts
// distribute across mesh parts (closes #56). _scriptRunner and
// transforms from the entity's exact current MeshRefs so
// multi-emitter scripts distribute across mesh parts (closes #56).
// Animated frames replace this snapshot through EntityEffectPoseRegistry.
// _scriptRunner and
// _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
@ -2309,12 +2327,21 @@ public sealed class GameWindow : IDisposable
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
if (e.IndexedPartTransforms.Count == 0)
{
var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
setup,
e);
e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
}
IReadOnlyList<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile);
profile,
availability);
}
catch
{
@ -2324,6 +2351,7 @@ public sealed class GameWindow : IDisposable
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!,
_particleSink!,
_effectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
@ -2364,12 +2392,24 @@ public sealed class GameWindow : IDisposable
}
}),
TearDownLiveEntityRuntimeComponents);
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities,
_effectPoses,
_lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
_effectPoses,
TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
@ -2378,6 +2418,7 @@ public sealed class GameWindow : IDisposable
_liveEntities,
_scriptRunner!,
tableResolver,
_effectPoses,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
@ -2389,8 +2430,14 @@ public sealed class GameWindow : IDisposable
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_equippedChildRenderer.EntityReady += guid =>
{
entityEffects.OnLiveEntityReady(guid);
};
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
@ -2619,6 +2666,7 @@ public sealed class GameWindow : IDisposable
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear();
}
}
@ -3626,14 +3674,39 @@ public sealed class GameWindow : IDisposable
// creatures/characters whose size is intrinsic to the mesh).
float scale = spawn.ObjScale ?? 1.0f;
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
IReadOnlyList<System.Numerics.Matrix4x4> rigidPartTransforms =
AcDream.Core.Meshing.SetupPartTransforms.Compute(
setup,
idleFrame,
scale);
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count];
var indexedPartAvailable = new bool[parts.Count];
var animatedPartTemplate = new AnimatedPartTemplate[parts.Count];
var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
int dumpClothingTotalTris = 0;
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
var mr = parts[partIdx];
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
surfaceOverrides = partOverrides;
// Keep the Setup index even when the visual GfxObj is absent.
// MeshRefs is a drawable-only list, while hooks and holding
// locations address the stable CPartArray slot number.
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
indexedPartTransforms[partIdx] = partIdx < rigidPartTransforms.Count
? rigidPartTransforms[partIdx]
: System.Numerics.Matrix4x4.Identity;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
bool isDrawable = gfx is not null;
indexedPartAvailable[partIdx] = isDrawable;
animatedPartTemplate[partIdx] = new AnimatedPartTemplate(
mr.GfxObjId,
surfaceOverrides,
isDrawable);
if (gfx is null)
{
if (dumpClothing)
@ -3650,10 +3723,6 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}");
}
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
surfaceOverrides = partOverrides;
// Multiplication order matches offline scenery hydration:
// `PartTransform * scaleMat`. In row-vector semantics this means
// "apply PartTransform first (which includes the part-attachment
@ -3663,8 +3732,6 @@ public sealed class GameWindow : IDisposable
// for multi-part entities like the Nullified Statue that causes
// the parts to drift relative to each other ("distorted") and the
// base anchor to end up below the ground ("sinks into foundry").
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
// #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb).
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) liveBounds.Add(transform, pb.Value);
@ -3719,10 +3786,17 @@ public sealed class GameWindow : IDisposable
{
AcDream.Core.World.WorldEntity existing = visualUpdate.Entity;
existing.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
existing.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var appearanceMin, out var appearanceMax))
existing.SetLocalBounds(appearanceMin, appearanceMax);
if (visualUpdate.Animation is { } animation)
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
RebindAnimatedEntityForAppearance(
animation,
existing,
setup,
scale,
animatedPartTemplate,
indexedPartAvailable);
_classificationCache.InvalidateEntity(existing.Id);
if (_liveEntities!.TryGetRecord(spawn.Guid, out LiveEntityRecord record)
&& record.ProjectionKind is LiveEntityProjectionKind.Attached)
@ -3732,6 +3806,14 @@ public sealed class GameWindow : IDisposable
// after replacing the child's visual description.
_equippedChildRenderer?.OnSpawn(spawn);
}
else
{
// SmartBox::UpdateVisualDesc mutates the existing PartArray.
// Publish those exact replacement part frames before another
// packet in this update can execute a part-attached hook.
_effectPoses.PublishMeshRefs(existing);
_equippedChildRenderer?.OnPosePublished(spawn.Guid);
}
return;
}
@ -3762,6 +3844,7 @@ public sealed class GameWindow : IDisposable
PartOverrides = entityPartOverrides,
ParentCellId = spawn.Position.Value.LandblockId,
};
created.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var createdMin, out var createdMax))
created.SetLocalBounds(createdMin, createdMax);
return created;
@ -3777,8 +3860,10 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rot;
entity.ParentCellId = spawn.Position.Value.LandblockId;
entity.ApplyAppearance(meshRefs, paletteOverride, entityPartOverrides);
entity.SetIndexedPartPoses(indexedPartTransforms, indexedPartAvailable);
if (liveBounds.TryGet(out var retainedMin, out var retainedMax))
entity.SetLocalBounds(retainedMin, retainedMax);
_effectPoses.PublishMeshRefs(entity);
}
// Retail CPhysicsObj::leave_world removes cell/shadow membership but
@ -3788,47 +3873,6 @@ public sealed class GameWindow : IDisposable
bool retainedAnimationRuntime = !createdProjection
&& _animatedEntities.TryGetValue(entity.Id, out _);
// A7 indoor lighting: server-spawned weenie fixtures (lanterns,
// braziers, glowing items) carry their light in Setup.Lights exactly
// like dat-baked statics, but the static registration in
// ApplyLoadedTerrainLocked never sees CreateObject entities — so an
// interior's lanterns cast no light and the room reads dark. Register
// them here, mirroring that path (GameWindow.cs ~6742). Owned by
// entity.Id, so leave-world and logical teardown both remove their
// cell-scoped presentation. Retail registers object-borne lights
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
// The light is placed at the spawn frame and does NOT follow a moving
// light-bearer yet (register row AP-44) — fine for stationary fixtures,
// which is the common case. _dats is non-null here (checked above).
if (_lightingSink is not null
&& (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var liteSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
if (liteSetup is not null && liteSetup.Lights.Count > 0)
{
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
liteSetup,
ownerId: entity.Id,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
// A7 fix #2 (#176/#181): isDynamic is decided by whether the light
// MOVES, not by weenie-vs-dat origin. Server-spawned FIXTURES
// (lanterns, braziers) are stationary — they take retail's STATIC
// curve (calc_point_light 0x0059c8b0: 1/d³ beyond 1 m, range×1.3,
// per-channel colour clamp), not the D3D dynamic path (1/d,
// range×1.5) the former #143 flag forced — which burned every
// fixture ~10× hotter than retail at 3 m: the magenta wash that
// zebra-striped the Facility Hub walls. Site-A lights are all
// stationary today (AP-44: they don't follow bearers); genuinely
// moving lights (projectiles, portal swirls) re-earn isDynamic
// when they exist.
isDynamic: false,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}
}
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
Id: entity.Id,
SourceId: entity.SourceGfxObjOrSetupId,
@ -3895,9 +3939,7 @@ public sealed class GameWindow : IDisposable
// Snapshot per-part identity from the hydrated meshRefs so the
// tick can rebuild MeshRefs without redoing AnimPartChanges or
// texture-override resolution every frame.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
AnimatedPartTemplate[] template = animatedPartTemplate;
// Create an AnimationSequencer if we can load the MotionTable.
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
@ -3925,6 +3967,7 @@ public sealed class GameWindow : IDisposable
Framerate = idleCycle.Framerate,
Scale = scale,
PartTemplate = template,
PartAvailability = indexedPartAvailable,
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
@ -3969,9 +4012,7 @@ public sealed class GameWindow : IDisposable
var sequencer = SpawnMotionInitializer.Create(
setup, mtable, _animLoader, spawn.MotionState);
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
AnimatedPartTemplate[] template = animatedPartTemplate;
_animatedEntities[entity.Id] = new AnimatedEntity
{
@ -3983,6 +4024,7 @@ public sealed class GameWindow : IDisposable
Framerate = 0f,
Scale = scale,
PartTemplate = template,
PartAvailability = indexedPartAvailable,
CurrFrame = 0,
Sequencer = sequencer,
};
@ -4124,15 +4166,14 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.WorldEntity entity,
DatReaderWriter.DBObjs.Setup setup,
float scale,
IReadOnlyList<AcDream.Core.World.MeshRef> meshRefs)
IReadOnlyList<AnimatedPartTemplate> partTemplate,
IReadOnlyList<bool> partAvailability)
{
animation.Entity = entity;
animation.Setup = setup;
animation.Scale = scale;
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
animation.PartTemplate = template;
animation.PartTemplate = partTemplate;
animation.PartAvailability = partAvailability;
}
/// <summary>
@ -4878,6 +4919,7 @@ public sealed class GameWindow : IDisposable
_translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record);
_liveEntityLights?.Forget(existingEntity.Id);
}
/// <summary>
@ -4897,10 +4939,10 @@ public sealed class GameWindow : IDisposable
if (record.ServerGuid == _playerServerGuid)
_lastLocalPlayerShadow = null;
// Object-borne lights are cell-scoped presentation. The owning Setup
// and logical light capability remain on the live record; re-entry
// registers the light in the new cell without duplicating it.
_lightingSink?.UnregisterOwner(entity.Id);
// Pose-attached effects keep logical ownership while cell-less. A
// missing pose suppresses stale anchors; re-entry republishes the same
// WorldEntity frames without replaying create-time resources.
_effectPoses.Remove(entity.Id);
}
/// <summary>
@ -5625,6 +5667,8 @@ public sealed class GameWindow : IDisposable
{
if (!_liveEntities!.TryApplyState(parsed, out _)) return;
_liveEntityLights?.OnStateChanged(parsed.Guid);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
// L.2g slice 1c (2026-05-13): the server addresses entities by
@ -6628,29 +6672,25 @@ public sealed class GameWindow : IDisposable
var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
seen.Add(key);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue;
uint skyEntityId = SkyPesEntityId(key);
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
_particleSink.SetEntityRenderPass(skyEntityId, renderPass);
var anchor = SkyPesAnchor(obj, cameraWorldPos);
var rotation = SkyPesRotation(obj, dayFraction);
// Refresh anchor + rotation every frame so AttachLocal
// (is_parent_local=1) particles track the camera. Retail
// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the
// live parent frame each tick; for sky-PES the parent IS
// the camera. UpdateEntityAnchor is a no-op when no
// emitters yet exist (script just spawned this frame).
_particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation);
_effectPoses.Publish(
skyEntityId,
System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
* System.Numerics.Matrix4x4.CreateTranslation(anchor),
System.Array.Empty<System.Numerics.Matrix4x4>(),
cellId: 0u);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue;
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
{
_activeSkyPes.Add(key);
@ -6660,6 +6700,7 @@ public sealed class GameWindow : IDisposable
_missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId);
_effectPoses.Remove(skyEntityId);
}
}
}
@ -6673,6 +6714,7 @@ public sealed class GameWindow : IDisposable
_scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_effectPoses.Remove(skyEntityId);
_activeSkyPes.Remove(key);
}
@ -9110,6 +9152,7 @@ public sealed class GameWindow : IDisposable
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
_animationHookFrames?.Drain();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can
@ -9276,8 +9319,10 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_entityEffects?.RefreshLiveOwnerAnchors();
_entityEffects?.RefreshLiveOwnerPoses();
_scriptRunner?.Tick(_physicsScriptGameTime);
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
_particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
@ -10369,33 +10414,13 @@ public sealed class GameWindow : IDisposable
};
}
// Phase E.1: drain animation hooks (footstep sounds, attack
// damage frames, particle spawns, part swaps, etc.) and fan
// them out to registered subsystems via the hook router.
// Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
//
// R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam;
// per-tick PLACEMENT provisional until R6 installs retail's
// UpdateObjectInternal order): each drained AnimDone hook
// advances the entity's MotionTableManager countdown
// (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone
// 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime
// runs once per tick to sweep zero-tick queue entries
// (retail call sites 0x00517d57/0x00517d67).
var hooks = ae.Sequencer.ConsumePendingHooks();
if (hooks.Count > 0)
{
System.Numerics.Vector3 worldPos = ae.Entity.Position;
for (int hi = 0; hi < hooks.Count; hi++)
{
var hook = hooks[hi];
if (hook is null) continue;
_hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
if (hook is DatReaderWriter.Types.AnimationDoneHook)
ae.Sequencer.Manager.AnimationDone(success: true);
}
}
ae.Sequencer.Manager.UseTime();
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
// This matches CPhysicsObj::UpdateObjectInternal followed by
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
else
{
@ -10475,6 +10500,8 @@ public sealed class GameWindow : IDisposable
// consumer caches a stale reference or diffs list identity).
var newMeshRefs = ae.MeshRefsScratch;
newMeshRefs.Clear();
var effectPartPoses = ae.EffectPartPosesScratch;
effectPartPoses.Clear();
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
@ -10532,7 +10559,7 @@ public sealed class GameWindow : IDisposable
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var partTransform =
var visualPartTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin);
@ -10540,14 +10567,23 @@ public sealed class GameWindow : IDisposable
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
partTransform = partTransform * scaleMat;
visualPartTransform *= scaleMat;
// Retail CPartArray::UpdateParts (0x005190F0) scales only the
// frame origin. Setup DefaultScale is visual gfxobj_scale,
// not part-frame state (SetScaleInternal 0x00518A00).
var rigidPartTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale);
effectPartPoses.Add(rigidPartTransform);
var template = ae.PartTemplate[i];
if (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)
if (!template.IsDrawable
|| (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10))
{
continue;
}
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
@ -10557,6 +10593,8 @@ public sealed class GameWindow : IDisposable
// property store) and keeps this line's shape identical to the
// pre-MP-Alloc code for anyone grepping the history.
ae.Entity.MeshRefs = newMeshRefs;
ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability);
_effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability);
}
}
@ -14019,7 +14057,11 @@ public sealed class GameWindow : IDisposable
}
finally
{
_liveEntityLights?.Dispose();
_liveEntityLights = null;
_entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear();
_effectPoses.Clear();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();

View file

@ -198,6 +198,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
{
if (!em.PresentationVisible)
continue;
if (em.RenderPass != renderPass)
continue;
if (emitterFilter is not null && !emitterFilter(em))

View file

@ -0,0 +1,89 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Defers animation-hook delivery until every entity and equipped child has
/// published its final pose for the frame.
/// </summary>
/// <remarks>
/// Retail stages hooks during sequence advance, then the same object update
/// commits the final part frame and runs <c>CPhysicsObj::UpdateChild</c>
/// (<c>0x00512D50</c>) before particles advance or anything draws. Capturing
/// here preserves that observable order and avoids firing a hand or weapon
/// effect against the previous animation frame.
/// </remarks>
public sealed class AnimationHookFrameQueue
{
private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses;
private readonly List<Entry> _entries = new();
public AnimationHookFrameQueue(
AnimationHookRouter router,
IEntityEffectPoseSource poses)
{
_router = router ?? throw new ArgumentNullException(nameof(router));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
}
public int Count => _entries.Count;
public void Capture(uint ownerLocalId, AnimationSequencer sequencer)
{
ArgumentNullException.ThrowIfNull(sequencer);
Capture(ownerLocalId, sequencer, sequencer.ConsumePendingHooks());
}
internal void Capture(
uint ownerLocalId,
AnimationSequencer sequencer,
IReadOnlyList<AnimationHook> hooks)
{
ArgumentNullException.ThrowIfNull(sequencer);
ArgumentNullException.ThrowIfNull(hooks);
_entries.Add(new Entry(
ownerLocalId,
sequencer,
hooks));
}
public void Drain()
{
for (int i = 0; i < _entries.Count; i++)
{
Entry entry = _entries[i];
bool hasRootPose = _poses.TryGetRootPose(
entry.OwnerLocalId,
out Matrix4x4 rootWorld);
Vector3 worldPosition = hasRootPose
? rootWorld.Translation
: Vector3.Zero;
for (int hi = 0; hi < entry.Hooks.Count; hi++)
{
AnimationHook? hook = entry.Hooks[hi];
if (hook is null)
continue;
if (hasRootPose)
_router.OnHook(entry.OwnerLocalId, worldPosition, hook);
if (hook is AnimationDoneHook)
entry.Sequencer.Manager.AnimationDone(success: true);
}
// Retail sweeps zero-tick motion entries even on frames without a
// hook. It remains paired with every sequencer advanced this frame.
entry.Sequencer.Manager.UseTime();
}
_entries.Clear();
}
public void Clear() => _entries.Clear();
private readonly record struct Entry(
uint OwnerLocalId,
AnimationSequencer Sequencer,
IReadOnlyList<AnimationHook> Hooks);
}

View file

@ -27,6 +27,7 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly LiveEntityRuntime _liveEntities;
private readonly PhysicsScriptRunner _runner;
private readonly PhysicsScriptTableResolver _tables;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func<uint, uint, uint?> _childAtPart;
private readonly Func<uint, uint?> _parentOfAttachedChild;
private readonly Action<uint> _ownerUnregistered;
@ -42,6 +43,7 @@ public sealed class EntityEffectController : IAnimationHookSink
LiveEntityRuntime liveEntities,
PhysicsScriptRunner runner,
PhysicsScriptTableResolver tables,
EntityEffectPoseRegistry poses,
Func<uint, uint, uint?>? childAtPart = null,
Func<uint, uint?>? parentOfAttachedChild = null,
Action<uint>? ownerUnregistered = null,
@ -50,6 +52,7 @@ public sealed class EntityEffectController : IAnimationHookSink
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
_tables = tables ?? throw new ArgumentNullException(nameof(tables));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_childAtPart = childAtPart ?? ((_, _) => null);
_parentOfAttachedChild = parentOfAttachedChild ?? (_ => null);
_ownerUnregistered = ownerUnregistered ?? (_ => { });
@ -201,8 +204,8 @@ public sealed class EntityEffectController : IAnimationHookSink
_pendingByServerGuid.Clear();
}
/// <summary>Refreshes every live root anchor after animation/movement.</summary>
public void RefreshLiveOwnerAnchors()
/// <summary>Refreshes every live root after animation/movement.</summary>
public void RefreshLiveOwnerPoses()
{
foreach (uint serverGuid in _readyServerGuids.ToArray())
{
@ -353,7 +356,18 @@ public sealed class EntityEffectController : IAnimationHookSink
&& record.WorldEntity is { } entity
&& entity.Id == localId)
{
_runner.SetOwnerAnchor(localId, entity.Position);
// Top-level roots follow the WorldEntity directly. Attached roots
// were already composed through the parent's current part by
// EquippedChildRenderController; overwriting one with the child's
// bookkeeping Position would collapse a weapon effect to the
// parent's origin.
if (record.ProjectionKind is LiveEntityProjectionKind.World)
_poses.UpdateRoot(entity);
Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
? rootWorld.Translation
: entity.Position;
_runner.SetOwnerAnchor(localId, anchor);
}
}

View file

@ -0,0 +1,216 @@
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
{
private sealed class PoseRecord
{
public Matrix4x4 RootWorld;
public Matrix4x4[] PartLocal = Array.Empty<Matrix4x4>();
public bool[] PartAvailable = Array.Empty<bool>();
public uint CellId;
}
private readonly Dictionary<uint, PoseRecord> _poses = new();
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.ParentCellId ?? 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);
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
{
record = new PoseRecord();
_poses.Add(entity.Id, record);
}
record.RootWorld = rootWorld;
record.CellId = entity.ParentCellId ?? 0u;
if (entity.IndexedPartTransforms.Count > 0)
{
CopyParts(
record,
entity.IndexedPartTransforms,
entity.IndexedPartAvailable);
}
else
{
if (record.PartLocal.Length != entity.MeshRefs.Count)
record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
if (record.PartAvailable.Length != entity.MeshRefs.Count)
record.PartAvailable = new bool[entity.MeshRefs.Count];
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
record.PartAvailable[i] = true;
}
}
}
public void Publish(
uint localEntityId,
Matrix4x4 rootWorld,
IReadOnlyList<Matrix4x4> partLocal,
uint cellId,
IReadOnlyList<bool>? availability = null)
{
if (localEntityId == 0)
return;
ArgumentNullException.ThrowIfNull(partLocal);
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
{
record = new PoseRecord();
_poses.Add(localEntityId, record);
}
record.RootWorld = rootWorld;
record.CellId = cellId;
CopyParts(record, partLocal, availability);
}
/// <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;
record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position);
record.CellId = entity.ParentCellId ?? 0u;
return true;
}
public bool Remove(uint localEntityId) => _poses.Remove(localEntityId);
public void Clear() => _poses.Clear();
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;
}
private static void 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.");
if (record.PartLocal.Length != partLocal.Count)
record.PartLocal = new Matrix4x4[partLocal.Count];
if (record.PartAvailable.Length != partLocal.Count)
record.PartAvailable = new bool[partLocal.Count];
for (int i = 0; i < partLocal.Count; i++)
{
record.PartLocal[i] = partLocal[i];
record.PartAvailable[i] = availability is null || availability[i];
}
}
}

View file

@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
@ -7,97 +5,57 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// What the activator's resolver returns when an entity's Setup carries
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
/// lookup yields both pieces of state. The activator pushes the part
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
/// the part-anchor pipeline introduced for issue #56.
/// Setup script/profile data resolved once when a logical effect owner is
/// registered. Part transforms are indexed and root-local.
/// </summary>
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null);
EntityEffectProfile? EffectProfile = null,
IReadOnlyList<bool>? PartAvailability = null);
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a <see cref="WorldEntity"/> enters the world, so static objects
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
/// emit their retail-faithful persistent particle effects automatically.
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned and dat-hydrated entities. Live owners use the
/// canonical local <c>entity.Id</c>. Dat-static IDs occupy disjoint, globally
/// unique ranges for interiors, scenery, and landblock stabs. The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
/// </para>
///
/// <para>
/// For live objects this is invoked by <c>LiveEntityRuntime</c> logical
/// registration/teardown. <c>GpuWorldState</c> invokes it only for dat-static
/// landblock load, promotion, demotion, and unload paths.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
/// runner; this class adds the missing fire-on-spawn call site.
/// </para>
/// Owns create-time Setup script activation and its symmetric teardown for
/// live and DAT-static entities.
/// </summary>
/// <remarks>
/// Setup <c>DefaultScript</c> is a direct PES played during Setup
/// initialization. Live registration invokes this class once per logical
/// generation; spatial rebucketing never replays it.
/// </remarks>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly EntityEffectPoseRegistry _poses;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
private readonly Action<uint, WorldEntity, EntityEffectProfile>? _registerDatStaticEffectOwner;
private readonly Action<uint>? _unregisterDatStaticEffectOwner;
private readonly Dictionary<uint, WorldEntity> _activeStaticOwners = new();
/// <param name="scriptRunner">Per-owner retail FIFO that schedules every
/// hook at its absolute script <c>StartTime</c>.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
/// per-entity emitter handles on despawn.</param>
/// <param name="resolver">Returns
/// <see cref="ScriptActivationInfo"/> with the entity's
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
/// throw / missing DefaultScript. Production lambda hits
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
EntityEffectPoseRegistry poses,
Func<WorldEntity, ScriptActivationInfo?> resolver,
Action<uint, WorldEntity, EntityEffectProfile>? registerDatStaticEffectOwner = null,
Action<uint>? unregisterDatStaticEffectOwner = null)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(resolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_resolver = resolver;
_scriptRunner = scriptRunner ?? throw new ArgumentNullException(nameof(scriptRunner));
_particleSink = particleSink ?? throw new ArgumentNullException(nameof(particleSink));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_registerDatStaticEffectOwner = registerDatStaticEffectOwner;
_unregisterDatStaticEffectOwner = unregisterDatStaticEffectOwner;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner under its canonical runtime identity.
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
if (key == 0) return; // malformed entity
if (key == 0)
return;
if (entity.ServerGuid == 0
&& _activeStaticOwners.TryGetValue(key, out WorldEntity? existing))
{
@ -107,56 +65,43 @@ public sealed class EntityScriptActivator
$"Dat-static WorldEntity id 0x{key:X8} is already active; static allocators must be globally unique.");
}
var info = _resolver(entity);
if (info is null) return;
ScriptActivationInfo? info = _resolver(entity);
if (info is null)
return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the
// hook fires. C.1.5a fix: without this, the sink falls through to
// Quaternion.Identity and the offset gets applied in world axes —
// visual symptom for portals: swirl oriented along world XYZ instead
// of the portal's facing, partially buried.
_particleSink.SetEntityRotation(key, entity.Rotation);
// C.1.5b #56: seed the sink's per-entity part transforms so
// CreateParticleHook.PartIndex routes the hook offset through the
// right mesh part's resting transform. Without this, every emitter
// in a multi-part Setup collapses to the entity root.
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
// Particle::Init (0x0051C930) samples the current root/part frames.
// Publish before the Setup default script can execute; effects never
// fall back to the camera or a cached spawn anchor.
_poses.Publish(entity, info.PartTransforms, info.PartAvailability);
if (entity.ServerGuid == 0)
_activeStaticOwners.Add(key, entity);
if (entity.ServerGuid == 0 && info.EffectProfile is { } profile)
_registerDatStaticEffectOwner?.Invoke(
key,
entity,
profile);
_registerDatStaticEffectOwner?.Invoke(key, entity, profile);
if (info.ScriptId != 0)
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to the runtime effect
/// owner. Idempotent for unknown entities.
/// </summary>
public void OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.Id;
if (key == 0) return;
if (key == 0)
return;
if (entity.ServerGuid == 0
&& (!_activeStaticOwners.TryGetValue(key, out WorldEntity? existing)
|| !ReferenceEquals(existing, entity)))
{
return;
}
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
_poses.Remove(key);
if (entity.ServerGuid == 0 && _activeStaticOwners.Remove(key))
_unregisterDatStaticEffectOwner?.Invoke(key);
}
}

View file

@ -0,0 +1,66 @@
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Reconstructs retail's stable CPartArray indices from a drawable-only
/// MeshRef projection. Rigid part frames come from Setup data while missing
/// DAT parts keep an unavailable placeholder at their stable Setup index.
/// Hydration omits by GfxObj DID and otherwise preserves Setup order, so
/// duplicate DIDs are all present or all absent; first-unconsumed matching is
/// lossless under that invariant.
/// </summary>
internal static class IndexedSetupPartPoseBuilder
{
public static (Matrix4x4[] Poses, bool[] Available) Build(
Setup setup,
WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(entity);
int partCount = setup.Parts.Count;
var poses = new Matrix4x4[partCount];
IReadOnlyList<Matrix4x4> defaults = SetupPartTransforms.Compute(
setup,
objectScale: entity.Scale);
for (int i = 0; i < partCount; i++)
poses[i] = i < defaults.Count ? defaults[i] : Matrix4x4.Identity;
var expectedGfx = new uint[partCount];
for (int i = 0; i < partCount; i++)
expectedGfx[i] = (uint)setup.Parts[i];
for (int i = 0; i < entity.PartOverrides.Count; i++)
{
PartOverride replacement = entity.PartOverrides[i];
if (replacement.PartIndex < expectedGfx.Length)
expectedGfx[replacement.PartIndex] = replacement.GfxObjId;
}
var available = new bool[partCount];
var consumed = new bool[entity.MeshRefs.Count];
for (int partIndex = 0; partIndex < partCount; partIndex++)
{
for (int drawableIndex = 0; drawableIndex < entity.MeshRefs.Count; drawableIndex++)
{
if (consumed[drawableIndex]
|| entity.MeshRefs[drawableIndex].GfxObjId != expectedGfx[partIndex])
{
continue;
}
consumed[drawableIndex] = true;
available[partIndex] = true;
// The drawable transform carries DefaultScale. Retail's
// attachment/effect frame is the rigid Setup frame computed
// above; matching only establishes which stable slot exists.
break;
}
}
return (poses, available);
}
}

View file

@ -0,0 +1,176 @@
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();
/// <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);
}
}

View file

@ -1311,7 +1311,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
// consumer of this exact indexing: one Matrix4x4 per
// consumer of this exact indexing: one rigid pose per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.