Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners. Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects. Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register. Co-Authored-By: Codex <noreply@openai.com>
171 lines
7.2 KiB
C#
171 lines
7.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
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.
|
|
/// </summary>
|
|
public sealed record ScriptActivationInfo(
|
|
uint ScriptId,
|
|
IReadOnlyList<Matrix4x4> PartTransforms);
|
|
|
|
/// <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, always keyed by
|
|
/// canonical local <c>entity.Id</c>. 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>
|
|
/// </summary>
|
|
public sealed class EntityScriptActivator
|
|
{
|
|
private readonly PhysicsScriptRunner _scriptRunner;
|
|
private readonly ParticleHookSink _particleSink;
|
|
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
|
|
private readonly HashSet<uint> _legacyPendingOwners = new();
|
|
|
|
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
|
|
/// (scriptId, entityId) instance table and schedules hooks at their
|
|
/// <c>StartTime</c> offsets.</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,
|
|
Func<WorldEntity, ScriptActivationInfo?> resolver)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scriptRunner);
|
|
ArgumentNullException.ThrowIfNull(particleSink);
|
|
ArgumentNullException.ThrowIfNull(resolver);
|
|
_scriptRunner = scriptRunner;
|
|
_particleSink = particleSink;
|
|
_resolver = resolver;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
|
|
/// the script runner, keyed by canonical local <c>entity.Id</c>.
|
|
/// 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
|
|
|
|
var info = _resolver(entity);
|
|
if (info is null || info.ScriptId == 0) 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);
|
|
|
|
_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 canonical
|
|
/// local entity id. Idempotent for unknown keys.
|
|
/// </summary>
|
|
public void OnRemove(uint key)
|
|
{
|
|
if (key == 0) return;
|
|
_scriptRunner.StopAllForEntity(key);
|
|
_particleSink.StopAllForEntity(key, fadeOut: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts the temporary server-GUID owner used when F754 precedes the
|
|
/// target's CreateObject/materialization. Step 4 replaces this with the
|
|
/// retail mixed pending-packet FIFO. Tracking is required because no
|
|
/// LiveEntityRecord may exist yet for delete/session teardown.
|
|
/// </summary>
|
|
public bool PlayLegacyPending(
|
|
uint serverGuid,
|
|
uint scriptDid,
|
|
Vector3 anchorWorldPosition)
|
|
{
|
|
if (serverGuid == 0)
|
|
return false;
|
|
bool played = _scriptRunner.Play(scriptDid, serverGuid, anchorWorldPosition);
|
|
if (played)
|
|
_legacyPendingOwners.Add(serverGuid);
|
|
return played;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cleans the temporary server-GUID owner used when F754 arrives before a
|
|
/// live projection has a canonical local ID. Step 4 replaces this alias
|
|
/// with the retail mixed pending-packet FIFO; until then teardown must stop
|
|
/// the alias as well as the normal local owner so an early effect cannot
|
|
/// outlive its object incarnation.
|
|
/// </summary>
|
|
public void OnRemoveLegacyOwner(uint serverGuid, uint canonicalLocalId)
|
|
{
|
|
if (serverGuid == 0 || serverGuid == canonicalLocalId)
|
|
return;
|
|
if (!_legacyPendingOwners.Remove(serverGuid))
|
|
return;
|
|
OnRemove(serverGuid);
|
|
}
|
|
|
|
/// <summary>Stops every pre-Create F754 alias at session teardown.</summary>
|
|
public void ClearLegacyPendingOwners()
|
|
{
|
|
foreach (uint serverGuid in _legacyPendingOwners)
|
|
OnRemove(serverGuid);
|
|
_legacyPendingOwners.Clear();
|
|
}
|
|
|
|
public int LegacyPendingOwnerCount => _legacyPendingOwners.Count;
|
|
}
|