diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index b0524ed..90472f6 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -180,6 +180,21 @@ public sealed class GpuWorldState _loaded[landblock.LandblockId] = landblock; if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); + + // C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0). + // Live entities (ServerGuid!=0) already had OnCreate fired at + // AppendLiveEntity; the filter avoids double-firing pending-bucket merges. + if (_entityScriptActivator is not null) + { + var loadedEntities = _loaded[landblock.LandblockId].Entities; + for (int i = 0; i < loadedEntities.Count; i++) + { + var e = loadedEntities[i]; + if (e.ServerGuid == 0) + _entityScriptActivator.OnCreate(e); + } + } + RebuildFlatView(); } @@ -245,6 +260,19 @@ public sealed class GpuWorldState _persistentRescued.Add(entity); } } + + // C.1.5b: stop DefaultScript for each dat-hydrated entity in + // the landblock. Server-spawned entities are either being + // rescued (script continues at the new LB) or were OnRemove'd + // via RemoveEntityByServerGuid earlier; leave them alone here. + if (_entityScriptActivator is not null) + { + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } + } } _pendingByLandblock.Remove(landblockId); @@ -408,6 +436,18 @@ public sealed class GpuWorldState // canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b. _onLandblockUnloaded?.Invoke(canonical); + // C.1.5b: stop DefaultScript for each dat-hydrated entity about to + // be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0 + // per this method's class doc-comment); the filter is belt-and-suspenders. + if (_entityScriptActivator is not null) + { + foreach (var entity in lb.Entities) + { + if (entity.ServerGuid == 0) + _entityScriptActivator.OnRemove(entity.Id); + } + } + _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); _pendingByLandblock.Remove(canonical); RebuildFlatView(); @@ -447,6 +487,17 @@ public sealed class GpuWorldState _loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); if (_wbSpawnAdapter is not null) _wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]); + + // C.1.5b: fire DefaultScript for each promoted dat-hydrated entity. + // All entities arriving via this path are atlas-tier by construction + // (the promotion path streams in dat-static scenery + EnvCell statics + // + stabs per the method's class doc-comment). + if (_entityScriptActivator is not null) + { + for (int i = 0; i < entities.Count; i++) + _entityScriptActivator.OnCreate(entities[i]); + } + RebuildFlatView(); } diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs new file mode 100644 index 0000000..e5a2034 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Streaming; + +/// +/// Phase C.1.5b: verifies fires +/// / +/// from the four +/// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, +/// RemoveLandblock, RemoveEntitiesFromLandblock), and that the +/// pending-bucket merge in AddLandblock does NOT double-fire for live +/// entities that already had OnCreate at . +/// +public sealed class GpuWorldStateActivatorTests +{ + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private sealed record Pipeline( + GpuWorldState State, + PhysicsScriptRunner Runner, + ParticleHookSink Sink, + RecordingSink Recording); + + private static Pipeline BuildPipeline(uint scriptId) + { + var script = new DatPhysicsScript(); + script.ScriptData.Add(new PhysicsScriptData + { + StartTime = 0.0, + Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }, + }); + var table = new Dictionary { [scriptId] = script }; + + var registry = new EmitterDescRegistry(); + var system = new ParticleSystem(registry); + var sink = new ParticleHookSink(system); + var recording = new RecordingSink(); + var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); + var activator = new EntityScriptActivator(runner, sink, + _ => new ScriptActivationInfo(scriptId, Array.Empty())); + + var state = new GpuWorldState(entityScriptActivator: activator); + return new Pipeline(state, runner, sink, recording); + } + + private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) + => new(canonicalId, new LandBlock(), entities); + + private static WorldEntity DatHydrated(uint id, Vector3 pos) => new() + { + Id = id, + ServerGuid = 0u, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + private static WorldEntity Live(uint serverGuid, Vector3 pos) => new() + { + Id = serverGuid, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + [Fact] + public void AddLandblock_FiresActivatorForDatHydratedEntity() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: new Vector3(1, 2, 3)); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + + p.State.AddLandblock(lb); + + // Tick fires the CreateParticleHook into RecordingSink. + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); + Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); + Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); + } + + [Fact] + public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() + { + // Live entity (ServerGuid!=0) arrives via AppendLiveEntity first — + // OnCreate fires once at that point. Then AddLandblock for the + // same canonical id pulls the pending entity into the loaded list. + // The new fire-site MUST NOT call OnCreate again (the live entity + // is filtered out by ServerGuid != 0). + var p = BuildPipeline(scriptId: 0xAAu); + var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); + + p.State.AppendLiveEntity(0xA9B4FFFFu, live); + var emptyLb = MakeStubLandblock(0xA9B4FFFFu); + p.State.AddLandblock(emptyLb); + + p.Runner.Tick(0.001f); + Assert.Single(p.Recording.Calls); // exactly one — no double-fire + Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); + } + + [Fact] + public void RemoveLandblock_FiresOnRemoveForDatHydratedEntity() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + + p.State.AddLandblock(lb); + // Don't Tick: Play queued the script in _active immediately; ticking + // would fire its single StartTime=0 hook and self-remove the script + // before we can observe RemoveLandblock cleaning it up. + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } + + [Fact] + public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() + { + var p = BuildPipeline(scriptId: 0xAAu); + var emptyLb = MakeStubLandblock(0xA9B4FFFFu); + p.State.AddLandblock(emptyLb); + + var promoted = new[] + { + DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero), + DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX), + }; + p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted); + + p.Runner.Tick(0.001f); + Assert.Equal(2, p.Recording.Calls.Count); + } + + [Fact] + public void RemoveEntitiesFromLandblock_FiresOnRemoveForDatHydratedEntities() + { + var p = BuildPipeline(scriptId: 0xAAu); + var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); + var lb = MakeStubLandblock(0xA9B4FFFFu, entity); + p.State.AddLandblock(lb); + // Don't Tick: see comment in RemoveLandblock_FiresOnRemoveForDatHydratedEntity. + Assert.Equal(1, p.Runner.ActiveScriptCount); + + p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu); + Assert.Equal(0, p.Runner.ActiveScriptCount); + } +}