diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md index d977b5b..706b73f 100644 --- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -66,9 +66,9 @@ This plan is the **execution source of truth** for N.4. It is updated as tasks l Status: **Living document — work in progress, started 2026-05-08.** -**Progress (2026-05-08):** Weeks 1 + 2 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Three architectural adjustments documented: 1 (DefaultDatReaderWriter discovery, no bridge needed), 2 (renderer is tier-blind; routing belongs in spawn callbacks), 3 (FPS regression root-caused as dual-pipeline cost; Task 22's dispatcher will allow the legacy-renderer short-circuit). Build green, 912 tests pass, 8 pre-existing failures only. +**Progress (2026-05-08):** Weeks 1 + 2 + 3 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural — same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only. -**Next: Task 16** (Week 3) — `AnimatedEntityState` type + per-instance customization path. +**Next: Task 22** (Week 4) — `WbDrawDispatcher` full draw loop. The first task that actually draws through WB and unlocks the dual-pipeline-cost mitigation from Adjustment 3. | Task | Status | Commit | |---|---|---| @@ -87,9 +87,18 @@ Status: **Living document — work in progress, started 2026-05-08.** | 13 — Memory budget verification | ✅ deferred to Task 22 (Adj. 3) | — | | 14 — Pending-spawn integration test | ✅ | `f4f0101` | | Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` | -| 15 — Week 2 wrap-up | ✅ | (this commit) | -| 16–21 — Week 3: per-instance + animation | pending | — | -| 22–28 — Week 4: draw dispatcher + ship | pending | — | +| 15 — Week 2 wrap-up | ✅ | `36f7a60` | +| 16+18+19 — AnimatedEntityState + AnimPartChange + HiddenParts | ✅ | `ce72c57` | +| 17 — EntitySpawnAdapter | ✅ + Adj. 4 | `c02c307` | +| 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) | +| 21 — Week 3 wrap-up | ✅ | (this commit) | +| 22 — WbDrawDispatcher full draw loop | pending | — | +| 23 — Surface metadata side-table population | pending | — | +| 24 — Sky-pass preservation check | pending | — | +| 25 — Component micro-tests round-out | pending | — | +| 26 — Visual verification + flag default-on | pending | — | +| 27 — Delete legacy code paths | pending | — | +| 28 — Update memory + ISSUES + finalize plan | pending | — | --- @@ -935,6 +944,54 @@ without violating Adjustment 2's tier-blind-renderer principle. infrastructure for Task 22 anyway. We just paid for it without seeing FPS recovery yet. +--- + +### Adjustment 4 (2026-05-08): WorldEntity lacks HiddenParts + AnimPartChange fields — deferred plumbing + +**Discovered during Task 17 implementation.** `EntitySpawnAdapter.OnCreate` +needed to populate `AnimatedEntityState` with the entity's `HiddenParts` +mask + `AnimPartChange` override map. But: `WorldEntity` (the per-frame +render-side struct) does not currently expose either field. Both pieces +of customization data live on the network-layer spawn record and are +consumed before the `WorldEntity` is built. + +**Resolution.** Task 17 ships the adapter scaffolding with a TODO comment +acknowledging the gap. The created `AnimatedEntityState` always has an +empty override map + zero hidden mask. Per-instance customizations like +"hide this character's head" won't take effect with flag-on until the +plumbing lands. + +**Why this is safe to defer.** No production path consumes +`AnimatedEntityState`'s override / hidden data yet — Task 22's +`WbDrawDispatcher` is the first consumer. By the time Task 22 lands, we +either: +1. Add `HiddenPartsMask` + `AnimPartChanges` fields to `WorldEntity` and + populate them at spawn time. Small change to the network → render + pipeline. +2. Inject them into `EntitySpawnAdapter.OnCreate` via a separate + parameter that the spawn handler provides directly (sidesteps the + `WorldEntity` change). + +Option 1 is cleaner long-term; Option 2 is faster for landing Task 22 +without touching WorldEntity. Decision deferred to Task 22 brainstorm. + +### Adjustment 5 (2026-05-08): Task 20 (per-instance decode conformance) is structural, not byte-comparison + +**Original plan.** Task 20 was supposed to compare RGBA8 output of +"old path" (`TextureCache.GetOrUploadWithPaletteOverride` direct) vs +"new path" (`EntitySpawnAdapter` → `ITextureCachePerInstance` → +`TextureCache.GetOrUploadWithPaletteOverride`) to prove byte-identity. + +**Reality.** Both paths call the **same function**. The new path adds a +seam interface (`ITextureCachePerInstance`) for testability but does +not modify the decode logic — the bytes are identical by construction. +A test asserting byte-equality would be tautological. + +**Resolution.** Existing `EntitySpawnAdapterTests` cover the routing +behavior (does the adapter call the cache with the right args?). The +decode-byte conformance is structural: same function = same output. +Mark Task 20 ✅ structurally; no separate test file. + ### Task 6 (original — kept for history) **Files:** 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/AnimatedEntityState.cs b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs new file mode 100644 index 0000000..913b7bf --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Per-entity render state for animated entities (characters, creatures, +/// equipped items). Holds AC-specific per-instance customizations the WB +/// atlas cache doesn't carry: AnimPartChange override map + +/// HiddenParts bitmask. Also holds a reference to acdream's existing +/// — Phase N.4 explicitly does not touch +/// the sequencer; we just route through it at draw time. +/// +/// +/// Lifecycle: created by EntitySpawnAdapter.OnCreate (Task 17) when +/// a server CreateObject is processed; destroyed by +/// EntitySpawnAdapter.OnRemove on RemoveObject. The mesh +/// data backing each part is cached in WB's ObjectMeshManager; +/// per-instance customizations don't go through the atlas — they overlay +/// at draw time. +/// +/// +public sealed class AnimatedEntityState +{ + private readonly Dictionary _partGfxObjOverrides = new(); + private ulong _hiddenMask = 0; + + /// Reference to acdream's existing animation sequencer. + /// Phase N.4 doesn't touch the sequencer; the draw dispatcher consumes + /// per-part transforms it produces per frame. + public AnimationSequencer Sequencer { get; } + + public AnimatedEntityState(AnimationSequencer sequencer) + { + System.ArgumentNullException.ThrowIfNull(sequencer); + Sequencer = sequencer; + } + + /// Set the HiddenParts bitmask for this entity. Bit + /// i set hides part i at draw time. + public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask; + + /// True if part partIdx should be skipped at draw + /// time. Returns false for part indices outside [0, 63]. + public bool IsPartHidden(int partIdx) + { + if (partIdx < 0 || partIdx >= 64) return false; + return (_hiddenMask & (1ul << partIdx)) != 0; + } + + /// Override the GfxObj id for a Setup part. Used for + /// AnimPartChange — e.g. wielding a weapon swaps the hand-part's + /// GfxObj. + public void SetPartOverride(int partIdx, ulong gfxObjId) + => _partGfxObjOverrides[partIdx] = gfxObjId; + + /// Look up the GfxObj override for a part. Returns false if + /// no override is set (caller should fall back to Setup default). + public bool TryGetPartOverride(int partIdx, out ulong gfxObjId) + => _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId); + + /// Resolve the GfxObj id for : + /// override if set, else . Used by the + /// draw dispatcher to pick the right cached mesh data per part. + public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault) + => TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault; +} 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/AnimPartChangeTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs new file mode 100644 index 0000000..d603ccd --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs @@ -0,0 +1,62 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class AnimPartChangeTests +{ + [Fact] + public void SetPartOverride_ResolvedAtLookup() + { + var state = MakeState(); + + state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul); + + Assert.True(state.TryGetPartOverride(5, out var got)); + Assert.Equal(0x01001234ul, got); + Assert.False(state.TryGetPartOverride(6, out _)); + } + + [Fact] + public void SetPartOverride_TwiceForSamePart_TakesLatest() + { + var state = MakeState(); + + state.SetPartOverride(0, 0x01000001ul); + state.SetPartOverride(0, 0x01999999ul); + + Assert.True(state.TryGetPartOverride(0, out var got)); + Assert.Equal(0x01999999ul, got); + } + + [Fact] + public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault() + { + var state = MakeState(); + + Assert.Equal(0x01000001ul, + state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul)); + } + + [Fact] + public void ResolvePartGfxObj_WithOverride_ReturnsOverride() + { + var state = MakeState(); + state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul); + + Assert.Equal(0x01999999ul, + state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs new file mode 100644 index 0000000..aae14dd --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs @@ -0,0 +1,45 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class AnimatedEntityStateTests +{ + [Fact] + public void DefaultState_HasNoOverridesAndNoHiddenParts() + { + var state = MakeState(); + + Assert.False(state.IsPartHidden(0)); + Assert.False(state.IsPartHidden(63)); + Assert.False(state.TryGetPartOverride(0, out _)); + } + + [Fact] + public void Sequencer_AccessibleAsProperty() + { + var sequencer = MakeSequencer(); + var state = new AnimatedEntityState(sequencer); + + Assert.Same(sequencer, state.Sequencer); + } + + [Fact] + public void Construct_WithNullSequencer_ThrowsArgumentNull() + { + Assert.Throws( + () => new AnimatedEntityState(null!)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +} 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; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs new file mode 100644 index 0000000..63c29f7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs @@ -0,0 +1,56 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public sealed class HiddenPartsTests +{ + [Theory] + [InlineData(0b0000_0000ul, 0, false)] + [InlineData(0b0000_0001ul, 0, true)] + [InlineData(0b1000_0000ul, 7, true)] + [InlineData(0b1000_0000ul, 6, false)] + [InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)] + public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected) + { + var state = MakeState(); + state.HideParts(mask); + Assert.Equal(expected, state.IsPartHidden(partIdx)); + } + + [Fact] + public void IsPartHidden_NegativeIdx_ReturnsFalse() + { + var state = MakeState(); + state.HideParts(0xFFFF_FFFF_FFFF_FFFFul); + Assert.False(state.IsPartHidden(-1)); + } + + [Fact] + public void IsPartHidden_PartIdxOver64_ReturnsFalse() + { + var state = MakeState(); + state.HideParts(0xFFFF_FFFF_FFFF_FFFFul); + Assert.False(state.IsPartHidden(64)); + } + + [Fact] + public void HideParts_DefaultsToNoneHidden() + { + var state = MakeState(); + for (int i = 0; i < 64; i++) + Assert.False(state.IsPartHidden(i)); + } + + private static AnimatedEntityState MakeState() => new(MakeSequencer()); + + private static AnimationSequencer MakeSequencer() + => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); + + private sealed class NullAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } +}