From c02c307bee3cf70a3b0ad04438e18dcbf1382e60 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 14:46:34 +0200 Subject: [PATCH] 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). 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 --- src/AcDream.App/Rendering/GameWindow.cs | 55 +++- src/AcDream.App/Rendering/TextureCache.cs | 2 +- .../Rendering/Wb/EntitySpawnAdapter.cs | 152 +++++++++++ .../Rendering/Wb/ITextureCachePerInstance.cs | 22 ++ src/AcDream.App/Streaming/GpuWorldState.cs | 15 +- .../Rendering/Wb/EntitySpawnAdapterTests.cs | 256 ++++++++++++++++++ 6 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs create mode 100644 src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 61f4084..28f3ff5 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1442,11 +1442,52 @@ public sealed class GameWindow : IDisposable // and rebuild _worldState so it threads the adapter in. _worldState starts // as an unadorned GpuWorldState (field initializer); here we replace it with // one that carries the adapter so AddLandblock/RemoveLandblock notify WB. + // Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned + // per-instance content under the same flag. { AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null; + AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null; if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null) + { wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter); - _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter); + // Sequencer factory: look up Setup + MotionTable from dats and build + // an AnimationSequencer. Falls back to a no-op sequencer when the + // entity has no motion table (static props, etc.). Uses _animLoader + // which is initialised at line 1004; it is non-null here because + // OnLoad wires _dats + _animLoader before this block runs. + var capturedDats = _dats; + var capturedAnimLoader = _animLoader; + AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e) + { + if (capturedDats is not null && capturedAnimLoader is not null) + { + var setup = capturedDats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + uint mtableId = (uint)setup.DefaultMotionTable; + if (mtableId != 0) + { + var mtable = capturedDats.Get(mtableId); + if (mtable is not null) + return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader); + } + // Setup exists but no motion table — no-op sequencer. + return new AcDream.Core.Physics.AnimationSequencer( + setup, + new DatReaderWriter.DBObjs.MotionTable(), + capturedAnimLoader); + } + } + // Complete fallback: empty setup + empty motion table + null loader. + return new AcDream.Core.Physics.AnimationSequencer( + new DatReaderWriter.DBObjs.Setup(), + new DatReaderWriter.DBObjs.MotionTable(), + new NullAnimLoader()); + } + wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( + _textureCache, SequencerFactory); + } + _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); } _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter); @@ -8745,4 +8786,16 @@ public sealed class GameWindow : IDisposable _ => $"Room 0x{roomId:X8}", }; } + + /// + /// Fallback for the + /// sequencer + /// factory when neither _dats nor the entity's setup is available. + /// Returns null for all animation lookups so the sequencer silently has + /// no data (same behaviour as a new empty Setup). + /// + private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader + { + public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; + } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index b5585c3..76dca7f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -8,7 +8,7 @@ using SurfaceType = DatReaderWriter.Enums.SurfaceType; namespace AcDream.App.Rendering; -public sealed unsafe class TextureCache : IDisposable +public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable { private readonly GL _gl; private readonly DatCollection _dats; diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs new file mode 100644 index 0000000..128d5dd --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Routes server-spawned (CreateObject) entities through the +/// per-instance rendering path. Server entities always carry per-instance +/// customizations (palette overrides, texture changes, part swaps) that +/// don't fit WB's atlas key, so they bypass the atlas and use the existing +/// +/// path which already hash-keys overrides for caching. +/// +/// +/// Companion to : that adapter handles +/// atlas-tier (procedural) entities; this one handles per-instance-tier +/// (server-spawned). The boundary is ServerGuid != 0 on +/// . +/// +/// +/// +/// Per-entity texture decode: when entity.PaletteOverride is +/// non-null, the adapter calls +/// +/// once per surface id that is known at spawn time (those on +/// ). Surfaces whose ids are only +/// discoverable by opening the GfxObj dat are decoded lazily by the draw +/// dispatcher (Task 22) on first use — that matches the existing +/// StaticMeshRenderer behavior. +/// +/// +/// +/// Sequencer factory: the adapter is constructed with a +/// Func<WorldEntity, AnimationSequencer> factory so tests can +/// inject a stub without needing a live DatCollection or MotionTable. +/// Production callers supply a factory that fetches MotionTable from dats. +/// +/// +/// +/// Adjustment 4: does not currently expose +/// HiddenPartsMask or AnimPartChanges as direct fields (those +/// live on the network-layer spawn record and are consumed upstream before +/// the is built). When those fields are promoted to +/// , should call +/// and +/// here. For now the mask +/// stays at 0 (no parts hidden) and no part overrides are set — the draw +/// dispatcher falls through to Setup defaults for every part. +/// +/// +public sealed class EntitySpawnAdapter +{ + private readonly ITextureCachePerInstance _textureCache; + private readonly Func _sequencerFactory; + + // Per-server-guid state. Written on OnCreate, released on OnRemove. + // Single-threaded: called only from the render thread (same as GpuWorldState). + private readonly Dictionary _stateByGuid = new(); + + /// + /// Per-instance texture decode path. In production this is the + /// instance (which implements + /// ); in tests it is a capturing mock. + /// + /// + /// Factory that builds an for a given + /// entity. Receives the full so it can look up + /// the Setup + MotionTable from the entity's SourceGfxObjOrSetupId + /// and server-supplied motion table override. Tests pass a lambda that + /// returns a stub sequencer. + /// + public EntitySpawnAdapter( + ITextureCachePerInstance textureCache, + Func sequencerFactory) + { + ArgumentNullException.ThrowIfNull(textureCache); + ArgumentNullException.ThrowIfNull(sequencerFactory); + _textureCache = textureCache; + _sequencerFactory = sequencerFactory; + } + + /// + /// Process a server-spawned entity. Returns the created + /// for the entity, or null if + /// is atlas-tier (ServerGuid == 0). + /// + public AnimatedEntityState? OnCreate(WorldEntity entity) + { + ArgumentNullException.ThrowIfNull(entity); + + // Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0) + // are handled by LandblockSpawnAdapter, not here. + if (entity.ServerGuid == 0) return null; + + // Pre-warm the per-instance texture cache for surfaces whose ids are + // already known at spawn time (those appearing as keys in + // MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't + // covered by SurfaceOverrides are decoded lazily by the draw + // dispatcher on first use — consistent with StaticMeshRenderer. + if (entity.PaletteOverride is { } paletteOverride) + { + foreach (var meshRef in entity.MeshRefs) + { + if (meshRef.SurfaceOverrides is null) continue; + + // SurfaceOverrides maps surfaceId → origTextureOverride (may be 0 + // meaning "no texture swap, just the palette override applies"). + foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides) + { + _textureCache.GetOrUploadWithPaletteOverride( + surfaceId, + origTexOverride == 0 ? null : origTexOverride, + paletteOverride); + } + } + } + + // Build the per-entity AnimatedEntityState. The sequencer factory + // may return a stub (in tests) or a fully-constructed sequencer from + // the MotionTable (in production). Factory must not return null — + // if the entity has no motion table the factory should construct a + // no-op sequencer (Setup + empty MotionTable + NullAnimationLoader). + var sequencer = _sequencerFactory(entity); + var state = new AnimatedEntityState(sequencer); + + // Adjustment 4 placeholder: when WorldEntity gains HiddenPartsMask + + // AnimPartChanges fields, apply them here: + // state.HideParts(entity.HiddenPartsMask); + // foreach (var apc in entity.AnimPartChanges) + // state.SetPartOverride(apc.PartIndex, apc.NewModelId); + + _stateByGuid[entity.ServerGuid] = state; + return state; + } + + /// + /// Release the per-entity state for . Called + /// on RemoveObject. Unknown guids (never spawned, or already + /// removed) are silently ignored. + /// + public void OnRemove(uint serverGuid) => _stateByGuid.Remove(serverGuid); + + /// + /// Look up the for a server guid. + /// Returns null if the entity was never spawned or has already + /// been removed. + /// + public AnimatedEntityState? GetState(uint serverGuid) + => _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null; +} diff --git a/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs b/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs new file mode 100644 index 0000000..491f11d --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs @@ -0,0 +1,22 @@ +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Seam interface over the per-instance palette-override decode path in +/// . Extracted so +/// can be tested without a live GL context. +/// +public interface ITextureCachePerInstance +{ + /// + /// Decode (or return cached) the palette-overridden texture for + /// . Delegates to + /// in + /// production. + /// + uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride); +} diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index bad81dd..7f6d228 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -40,10 +40,14 @@ namespace AcDream.App.Streaming; public sealed class GpuWorldState { private readonly LandblockSpawnAdapter? _wbSpawnAdapter; + private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter; - public GpuWorldState(LandblockSpawnAdapter? wbSpawnAdapter = null) + public GpuWorldState( + LandblockSpawnAdapter? wbSpawnAdapter = null, + EntitySpawnAdapter? wbEntitySpawnAdapter = null) { _wbSpawnAdapter = wbSpawnAdapter; + _wbEntitySpawnAdapter = wbEntitySpawnAdapter; } private readonly Dictionary _loaded = new(); @@ -246,6 +250,10 @@ public sealed class GpuWorldState { if (serverGuid == 0) return; + // Phase N.4 Task 17: release per-instance state for server-spawned + // entities. No-op for atlas-tier entities (never registered). + _wbEntitySpawnAdapter?.OnRemove(serverGuid); + bool rebuiltLoaded = false; // Scan loaded landblocks. ToArray() so we can mutate _loaded inside. @@ -301,6 +309,11 @@ public sealed class GpuWorldState /// public void AppendLiveEntity(uint landblockId, WorldEntity entity) { + // Phase N.4 Task 17: route server-spawned entities through the + // per-instance adapter. Atlas-tier entities (ServerGuid == 0) are + // skipped by OnCreate — it returns null immediately for those. + _wbEntitySpawnAdapter?.OnCreate(entity); + uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs new file mode 100644 index 0000000..016ce65 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs @@ -0,0 +1,256 @@ +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 + { + { 0x08000100u, 0u }, // surfaceId → origTex (0 = none) + }, + }, + new MeshRef(0x01000020u, Matrix4x4.Identity) + { + SurfaceOverrides = new Dictionary + { + { 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()), + // 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 ───────────────────────────────────────────────────── + + /// + /// Capture every call to GetOrUploadWithPaletteOverride so tests can + /// assert without a live GL context. + /// + private sealed class CapturingTextureCache : ITextureCachePerInstance + { + public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette); + public List 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; + } +}