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; + } +}