feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities
Four new foreach blocks in GpuWorldState wire EntityScriptActivator into the dat-hydration spawn/despawn paths: - AddLandblock: fires OnCreate for each entity with ServerGuid==0 (live entities filtered out — they got OnCreate at AppendLiveEntity and would double-fire on pending-bucket merges). - AddEntitiesToExistingLandblock: fires OnCreate for each entity in the promoted batch (all dat-hydrated by construction). - RemoveLandblock: fires OnRemove(entity.Id) for each ServerGuid==0 entity before the loaded record is dropped. - RemoveEntitiesFromLandblock: fires OnRemove for the demote-tier entities about to be cleared (Near→Far demotion). 5 new integration tests cover the four fire-sites + the no-double-fire invariant on pending-bucket merges. Pattern matches existing GpuWorldStateTests (stub LandBlock heightmap + WorldEntity factory). Closes #56 end-to-end. Slice A (per-part transforms in Tasks 1-3) + Slice B (dat-hydrated entity DefaultScript firing, this task) both ready for visual verification at Holtburg portal + Inn fireplace + cottage chimney + spell cast. Note: 8 pre-existing failures in Physics/Input/MotionInterpreter test families are unrelated to this work (verified by re-running with this task's changes stashed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5ca5827abe
commit
8735c39a40
2 changed files with 220 additions and 0 deletions
|
|
@ -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<WorldEntity>());
|
||||
_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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase C.1.5b: verifies <see cref="GpuWorldState"/> fires
|
||||
/// <see cref="EntityScriptActivator.OnCreate"/> /
|
||||
/// <see cref="EntityScriptActivator.OnRemove"/> 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 <see cref="GpuWorldState.AppendLiveEntity"/>.
|
||||
/// </summary>
|
||||
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<uint, DatPhysicsScript> { [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<Matrix4x4>()));
|
||||
|
||||
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<MeshRef>(),
|
||||
};
|
||||
|
||||
private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
|
||||
{
|
||||
Id = serverGuid,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = pos,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue