acdream/tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs
Erik 438bb681a5 feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630)
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:30:20 +02:00

170 lines
6.3 KiB
C#

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;
using DatFrame = DatReaderWriter.Types.Frame;
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 DatFrame() },
});
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);
}
}