feat(vfx): port retail effect scheduling and delivery

This commit is contained in:
Erik 2026-07-14 09:25:44 +02:00
parent 363e046112
commit 96ddfdf175
28 changed files with 2473 additions and 691 deletions

View file

@ -333,10 +333,13 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// from the server and schedules the dat-defined hooks (particle spawns,
// sounds, light toggles) at their StartTime offsets.
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
// and nested/default-script routing at its StartTime offset.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
@ -1451,7 +1454,8 @@ public sealed class GameWindow : IDisposable
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_particleSink);
_hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
@ -2295,16 +2299,22 @@ public sealed class GameWindow : IDisposable
// _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.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
{
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile);
}
catch
{
@ -2312,7 +2322,17 @@ public sealed class GameWindow : IDisposable
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _particleSink!, ResolveActivation);
_scriptRunner!,
_particleSink!,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
_lightingSink?.UnregisterOwner(ownerId);
_translucencyFades.ClearEntity(ownerId);
});
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
@ -2336,7 +2356,7 @@ public sealed class GameWindow : IDisposable
{
try
{
entityScriptActivator.OnRemove(entity.Id);
entityScriptActivator.OnRemove(entity);
}
finally
{
@ -2352,6 +2372,27 @@ public sealed class GameWindow : IDisposable
_liveEntities,
TryAcceptParentForRender);
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
_equippedChildRenderer.EntityReady += guid =>
entityEffects.OnLiveEntityReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades);
@ -2575,9 +2616,9 @@ public sealed class GameWindow : IDisposable
}
finally
{
// F754 can precede CreateObject, so some temporary owners have no
// LiveEntityRecord for the normal logical teardown to visit.
_entityScriptActivator?.ClearLegacyPendingOwners();
// F754/F755 can precede CreateObject, so the mixed pending FIFO
// may contain owners with no LiveEntityRecord to tear down.
_entityEffects?.ClearNetworkState();
}
}
@ -2617,6 +2658,8 @@ public sealed class GameWindow : IDisposable
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
_liveSession.PlayPhysicsScriptTypeReceived += message =>
_entityEffects?.HandleTyped(message);
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
@ -3886,16 +3929,6 @@ public sealed class GameWindow : IDisposable
Sequencer = sequencer,
};
// Phase E.2: register entity's SoundTable so SoundTableHook can
// resolve creature-specific sounds (footsteps, attack vocalizations,
// damage grunts, etc). Server-sent SoundTable override would take
// precedence here when the wire layer delivers it.
if (_entitySoundTables is not null)
{
uint soundTableId = (uint)setup.DefaultSoundTable;
if (soundTableId != 0)
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
else if (!retainedAnimationRuntime && _animLoader is not null)
{
@ -3964,6 +3997,11 @@ public sealed class GameWindow : IDisposable
}
}
// Renderer, script owner, optional animation owner, collision body,
// and effect profile are now all installed. Only at this boundary may
// the mixed pre-Create F754/F755 FIFO replay against the local ID.
_entityEffects?.OnLiveEntityReady(spawn.Guid);
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
@ -4007,12 +4045,11 @@ public sealed class GameWindow : IDisposable
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{
// A Delete can arrive before CreateObject, in which case no instance
// timestamp owner exists and the tracked F754 alias must be cleaned
// directly. For a known record, cleanup belongs after the runtime's
// generation gate: a stale Delete must not cancel the current
// incarnation's effect.
// timestamp owner exists and its mixed pending effect FIFO must be
// discarded directly. For a known record, cleanup belongs after the
// generation gate: a stale Delete must not cancel the current owner.
if (_liveEntities?.TryGetRecord(delete.Guid, out _) != true)
_entityScriptActivator?.OnRemoveLegacyOwner(delete.Guid, 0u);
_entityEffects?.ForgetUnknownOwner(delete.Guid);
bool removed = _liveEntities!.UnregisterLiveEntity(
delete,
isLocalPlayer: delete.Guid == _playerServerGuid,
@ -4379,6 +4416,7 @@ public sealed class GameWindow : IDisposable
&& effectProfile is AcDream.App.Rendering.Vfx.EntityEffectProfile liveProfile)
{
liveProfile.ApplyNetworkDescription(refresh.Description);
_entityEffects?.OnLiveEntityDescriptionChanged(refresh.Appearance.Guid);
}
OnLiveAppearanceUpdated(refresh.Appearance);
@ -4785,15 +4823,7 @@ public sealed class GameWindow : IDisposable
private void TearDownLiveEntityRuntimeComponents(LiveEntityRecord record)
{
// AD-32 transitional F754 ownership: before Step 4's mixed pending
// packet FIFO, a direct script received before materialization is
// temporarily keyed by server GUID. It is still part of this logical
// incarnation and must be stopped even when no WorldEntity was ever
// materialized. Normal Setup/F754 owners use the local ID and are
// stopped by the resource lifecycle after this callback.
_entityScriptActivator?.OnRemoveLegacyOwner(
record.ServerGuid,
record.LocalEntityId ?? 0u);
_entityEffects?.OnLiveEntityUnregistered(record);
if (record.WorldEntity is not { } existingEntity)
return;
@ -6565,13 +6595,8 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// Known server GUIDs are translated to the canonical local entity ID so
/// logical teardown stops both Setup and network-triggered scripts through
/// one owner key. Their current entity position is the anchor. Unknown
/// owners retain the legacy camera anchor until Step 4's pending FIFO can
/// replay them after materialization.
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// GUID translation and pre-materialization queueing.
///
/// <para>
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
@ -6580,27 +6605,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
{
if (_scriptRunner is null) return;
var camWorldPos = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
System.Numerics.Vector3 anchor = camWorldPos;
if (_liveEntities?.TryGetWorldEntity(message.Guid, out var entity) == true)
{
anchor = entity.Position;
_scriptRunner.Play(message.ScriptDid, entity.Id, anchor);
return;
}
_entityScriptActivator?.PlayLegacyPending(
message.Guid,
message.ScriptDid,
anchor);
_entityEffects?.HandleDirect(message);
}
private void UpdateSkyPes(
@ -6628,6 +6633,7 @@ public sealed class GameWindow : IDisposable
continue;
uint skyEntityId = SkyPesEntityId(key);
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
@ -6652,6 +6658,7 @@ public sealed class GameWindow : IDisposable
else
{
_missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId);
}
}
@ -6663,7 +6670,8 @@ public sealed class GameWindow : IDisposable
continue;
uint skyEntityId = SkyPesEntityId(key);
_scriptRunner.Stop(key.PesObjectId, skyEntityId);
_scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_activeSkyPes.Remove(key);
}
@ -6687,8 +6695,8 @@ public sealed class GameWindow : IDisposable
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
}
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
entity.Id;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
@ -7221,6 +7229,10 @@ public sealed class GameWindow : IDisposable
}
}
if (localIndex > 0xFFu)
throw new InvalidDataException(
$"Landblock 0x{lb.LandblockId:X8} exceeds the 256-entry procedural scenery id namespace.");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
@ -7292,7 +7304,6 @@ public sealed class GameWindow : IDisposable
// why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu;
uint interiorIdBase = AcDream.Core.World.InteriorEntityIdAllocator.Base(interiorLbX, interiorLbY);
uint localCounter = 0;
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
@ -7481,20 +7492,12 @@ public sealed class GameWindow : IDisposable
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;
// #190: never silently wrap past the counter budget — that's exactly how
// this bug hid the first time (see the InteriorEntityIdAllocator doc
// comment). Log once per landblock the moment it happens; the id below
// still aliases into the next Y-slot when this fires (4096 is generous
// but not infinite), but at least it's visible in launch.log instead of
// silently breaking entity.Id-keyed systems (scripts, particles, shadows).
if (localCounter == AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1)
Console.WriteLine(
$"[id-overflow] landblock 0x{landblockId:X8} exceeded {AcDream.Core.World.InteriorEntityIdAllocator.MaxCounter + 1} " +
"interior entities — ids are now aliasing into the next landblock's reserved range (#190 class)");
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = interiorIdBase + localCounter++,
Id = AcDream.Core.World.InteriorEntityIdAllocator.Allocate(
interiorLbX,
interiorLbY,
ref localCounter),
SourceGfxObjOrSetupId = stab.Id,
Position = worldPos,
Rotation = worldRot,
@ -7927,11 +7930,12 @@ public sealed class GameWindow : IDisposable
// over time, and the stacked ties destabilize the 128-cap pool
// sort). Clear the owner's previous registration first: the
// re-apply becomes idempotent, and a first apply is a no-op.
_lightingSink.UnregisterOwner(entity.Id);
_translucencyFades.ClearEntity(entity.Id); // #188
uint effectOwnerId = entity.Id;
_lightingSink.UnregisterOwner(effectOwnerId);
_translucencyFades.ClearEntity(effectOwnerId); // #188
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
datSetup,
ownerId: entity.Id,
ownerId: effectOwnerId,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
@ -8212,6 +8216,13 @@ public sealed class GameWindow : IDisposable
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
_physicsScriptGameTime += Math.Max(0.0, dt);
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
// boundary, independent of where in OnUpdate the applies landed) and reset.
@ -9090,6 +9101,12 @@ public sealed class GameWindow : IDisposable
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
// PhysicsScript timed hooks and animation hooks read the same current
// retail update-frame clock published before network dispatch. Drain
// the script pass later after final root/part poses are composed.
// Re-publish without advancing so an extra render between updates still
// retains retail's animation-hook versus object-hook phase barrier.
_scriptRunner?.PublishTime(_physicsScriptGameTime);
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick();
@ -9259,7 +9276,8 @@ public sealed class GameWindow : IDisposable
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_scriptRunner?.Tick((float)deltaSeconds);
_entityEffects?.RefreshLiveOwnerAnchors();
_scriptRunner?.Tick(_physicsScriptGameTime);
_particleSystem?.Tick((float)deltaSeconds);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
@ -14001,7 +14019,7 @@ public sealed class GameWindow : IDisposable
}
finally
{
_entityScriptActivator?.ClearLegacyPendingOwners();
_entityEffects?.ClearNetworkState();
}
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();