acdream/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
Erik c02c307bee phase(N.4) Task 17: EntitySpawnAdapter for server-spawned per-instance content
Routes server-spawned (CreateObject) entities through the per-instance
rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural,
ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead.

For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides
map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the
palette-composed GL texture before the first draw. Surfaces not in the
SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj
dat) are decoded lazily by the draw dispatcher on first use, consistent with
StaticMeshRenderer.

Builds AnimatedEntityState per server-guid via injected sequencer factory
(Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter
from DatCollection so tests pass a stub lambda without a GL context.

OnRemove releases per-entity state. Unknown guids no-op.

Introduces ITextureCachePerInstance: thin seam interface over the palette
decode path so EntitySpawnAdapter tests can use a CapturingTextureCache
mock without constructing a GL context. TextureCache implements it.

Adjustment 4 documented in source comments: WorldEntity does not currently
expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the
network layer before the WorldEntity is built). HideParts / SetPartOverride
calls are placeholder TODO'd for when those fields are promoted.

Wired into GpuWorldState.AppendLiveEntity (OnCreate) and
RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the
ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer
factory captures _dats + _animLoader at construction time; falls back to an
empty Setup + MotionTable via NullAnimLoader when dats are unavailable.

10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm
(with and without surface overrides), OnRemove lifecycle, unknown-guid noop,
multi-entity isolation. All pass; 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:46:34 +02:00

256 lines
8.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
public sealed class EntitySpawnAdapterTests
{
// ── Happy-path: server-spawned entity ─────────────────────────────────
[Fact]
public void OnCreate_ServerSpawnedEntity_RegistersAnimatedEntityState()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0001u);
var state = adapter.OnCreate(entity);
Assert.NotNull(state);
Assert.Same(state, adapter.GetState(0xDEAD0001u));
}
[Fact]
public void OnCreate_ServerSpawnedEntity_SequencerIsNotNull()
{
var adapter = MakeAdapter();
var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0002u);
var state = adapter.OnCreate(entity);
Assert.NotNull(state!.Sequencer);
}
// ── Atlas-tier filter ─────────────────────────────────────────────────
[Fact]
public void OnCreate_ProceduralEntity_ReturnsNullAndRegistersNothing()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
// ServerGuid == 0 → atlas-tier, must not be processed here.
var entity = MakeEntity(id: 2, serverGuid: 0u);
var state = adapter.OnCreate(entity);
Assert.Null(state);
Assert.Null(adapter.GetState(0u));
// No texture decode should have been triggered.
Assert.Empty(cache.Calls);
}
// ── Palette-override texture decode ───────────────────────────────────
[Fact]
public void OnCreate_WithPaletteOverrideAndSurfaceOverrides_TriggersTextureCacheDecode()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var palette = new PaletteOverride(
BasePaletteId: 0x04001234u,
SubPalettes: new[]
{
new PaletteOverride.SubPaletteRange(0x04002000u, 0, 2),
});
// Entity carries two parts each with one surface override.
var entity = new WorldEntity
{
Id = 10,
ServerGuid = 0xBEEF0001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
PaletteOverride = palette,
MeshRefs = new[]
{
new MeshRef(0x01000010u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
{ 0x08000100u, 0u }, // surfaceId → origTex (0 = none)
},
},
new MeshRef(0x01000020u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
{ 0x08000200u, 0x05000300u }, // with origTex override
},
},
},
};
adapter.OnCreate(entity);
// One call per surface-with-override: (0x08000100, null) and (0x08000200, 0x05000300).
Assert.Equal(2, cache.Calls.Count);
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000100u
&& c.OrigTexOverride == null
&& c.Palette == palette);
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000200u
&& c.OrigTexOverride == 0x05000300u
&& c.Palette == palette);
}
[Fact]
public void OnCreate_WithPaletteOverrideButNoSurfaceOverrides_DoesNotCallCache()
{
// Surfaces without SurfaceOverrides == null are decoded lazily at draw
// time; the adapter only pre-warms what it knows at spawn time.
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var entity = new WorldEntity
{
Id = 11,
ServerGuid = 0xBEEF0002u,
SourceGfxObjOrSetupId = 0x02000002u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
PaletteOverride = new PaletteOverride(0x04001235u, Array.Empty<PaletteOverride.SubPaletteRange>()),
// MeshRef with NO SurfaceOverrides.
MeshRefs = new[] { new MeshRef(0x01000011u, Matrix4x4.Identity) },
};
adapter.OnCreate(entity);
Assert.Empty(cache.Calls);
}
[Fact]
public void OnCreate_WithoutPaletteOverride_DoesNotCallCache()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var entity = MakeEntity(id: 12, serverGuid: 0xBEEF0003u);
adapter.OnCreate(entity);
Assert.Empty(cache.Calls);
}
// ── OnRemove ─────────────────────────────────────────────────────────
[Fact]
public void OnRemove_ReleasesPerEntityState()
{
var adapter = MakeAdapter();
var entity = MakeEntity(id: 20, serverGuid: 0xCAFE0001u);
adapter.OnCreate(entity);
Assert.NotNull(adapter.GetState(0xCAFE0001u));
adapter.OnRemove(0xCAFE0001u);
Assert.Null(adapter.GetState(0xCAFE0001u));
}
[Fact]
public void OnRemove_UnknownGuid_NoOps()
{
var adapter = MakeAdapter();
// Must not throw.
adapter.OnRemove(0xDEADBEEFu);
}
// ── Multiple entities ─────────────────────────────────────────────────
[Fact]
public void OnCreate_MultipleEntities_EachGetsOwnState()
{
var adapter = MakeAdapter();
var e1 = MakeEntity(id: 30, serverGuid: 0x11110001u);
var e2 = MakeEntity(id: 31, serverGuid: 0x11110002u);
var s1 = adapter.OnCreate(e1);
var s2 = adapter.OnCreate(e2);
Assert.NotNull(s1);
Assert.NotNull(s2);
Assert.NotSame(s1, s2);
Assert.Same(s1, adapter.GetState(0x11110001u));
Assert.Same(s2, adapter.GetState(0x11110002u));
}
[Fact]
public void OnRemove_OnlyReleasesTargetGuid()
{
var adapter = MakeAdapter();
var e1 = MakeEntity(id: 40, serverGuid: 0x22220001u);
var e2 = MakeEntity(id: 41, serverGuid: 0x22220002u);
adapter.OnCreate(e1);
adapter.OnCreate(e2);
adapter.OnRemove(0x22220001u);
Assert.Null(adapter.GetState(0x22220001u));
Assert.NotNull(adapter.GetState(0x22220002u));
}
// ── Helpers ───────────────────────────────────────────────────────────
private static EntitySpawnAdapter MakeAdapter(ITextureCachePerInstance? cache = null)
{
cache ??= new CapturingTextureCache();
return new EntitySpawnAdapter(cache, _ => MakeSequencer());
}
private static WorldEntity MakeEntity(uint id, uint serverGuid)
=> new WorldEntity
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) },
};
private static AnimationSequencer MakeSequencer()
=> new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
// ── Mocks / stubs ─────────────────────────────────────────────────────
/// <summary>
/// Capture every call to GetOrUploadWithPaletteOverride so tests can
/// assert without a live GL context.
/// </summary>
private sealed class CapturingTextureCache : ITextureCachePerInstance
{
public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette);
public List<Call> Calls { get; } = new();
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
{
Calls.Add(new Call(surfaceId, overrideOrigTextureId, paletteOverride));
return 1u; // Fake GL handle.
}
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
}