Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 3 complete

This commit is contained in:
Erik 2026-05-08 14:48:21 +02:00
commit d30fcb2eb0
11 changed files with 791 additions and 8 deletions

View file

@ -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.** 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 | | 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) | — | | 13 — Memory budget verification | ✅ deferred to Task 22 (Adj. 3) | — |
| 14 — Pending-spawn integration test | ✅ | `f4f0101` | | 14 — Pending-spawn integration test | ✅ | `f4f0101` |
| Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` | | Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` |
| 15 — Week 2 wrap-up | ✅ | (this commit) | | 15 — Week 2 wrap-up | ✅ | `36f7a60` |
| 1621 — Week 3: per-instance + animation | pending | — | | 16+18+19 — AnimatedEntityState + AnimPartChange + HiddenParts | ✅ | `ce72c57` |
| 2228 — Week 4: draw dispatcher + ship | pending | — | | 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 infrastructure for Task 22 anyway. We just paid for it without
seeing FPS recovery yet. 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) ### Task 6 (original — kept for history)
**Files:** **Files:**

View file

@ -1442,11 +1442,52 @@ public sealed class GameWindow : IDisposable
// and rebuild _worldState so it threads the adapter in. _worldState starts // and rebuild _worldState so it threads the adapter in. _worldState starts
// as an unadorned GpuWorldState (field initializer); here we replace it with // as an unadorned GpuWorldState (field initializer); here we replace it with
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB. // 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.LandblockSpawnAdapter? wbSpawnAdapter = null;
AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null) if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
{
wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter); 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<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(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); _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
@ -8745,4 +8786,16 @@ public sealed class GameWindow : IDisposable
_ => $"Room 0x{roomId:X8}", _ => $"Room 0x{roomId:X8}",
}; };
} }
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
/// factory when neither <c>_dats</c> 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).
/// </summary>
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
} }

View file

@ -8,7 +8,7 @@ using SurfaceType = DatReaderWriter.Enums.SurfaceType;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : IDisposable public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable
{ {
private readonly GL _gl; private readonly GL _gl;
private readonly DatCollection _dats; private readonly DatCollection _dats;

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-entity render state for animated entities (characters, creatures,
/// equipped items). Holds AC-specific per-instance customizations the WB
/// atlas cache doesn't carry: <c>AnimPartChange</c> override map +
/// <c>HiddenParts</c> bitmask. Also holds a reference to acdream's existing
/// <see cref="AnimationSequencer"/> — Phase N.4 explicitly does not touch
/// the sequencer; we just route through it at draw time.
///
/// <para>
/// Lifecycle: created by <c>EntitySpawnAdapter.OnCreate</c> (Task 17) when
/// a server <c>CreateObject</c> is processed; destroyed by
/// <c>EntitySpawnAdapter.OnRemove</c> on <c>RemoveObject</c>. The mesh
/// data backing each part is cached in WB's <c>ObjectMeshManager</c>;
/// per-instance customizations don't go through the atlas — they overlay
/// at draw time.
/// </para>
/// </summary>
public sealed class AnimatedEntityState
{
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
private ulong _hiddenMask = 0;
/// <summary>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.</summary>
public AnimationSequencer Sequencer { get; }
public AnimatedEntityState(AnimationSequencer sequencer)
{
System.ArgumentNullException.ThrowIfNull(sequencer);
Sequencer = sequencer;
}
/// <summary>Set the <c>HiddenParts</c> bitmask for this entity. Bit
/// <c>i</c> set hides part <c>i</c> at draw time.</summary>
public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
/// <summary>True if part <c>partIdx</c> should be skipped at draw
/// time. Returns false for part indices outside [0, 63].</summary>
public bool IsPartHidden(int partIdx)
{
if (partIdx < 0 || partIdx >= 64) return false;
return (_hiddenMask & (1ul << partIdx)) != 0;
}
/// <summary>Override the GfxObj id for a Setup part. Used for
/// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
/// GfxObj.</summary>
public void SetPartOverride(int partIdx, ulong gfxObjId)
=> _partGfxObjOverrides[partIdx] = gfxObjId;
/// <summary>Look up the GfxObj override for a part. Returns false if
/// no override is set (caller should fall back to Setup default).</summary>
public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
=> _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
/// <summary>Resolve the GfxObj id for <paramref name="partIdx"/>:
/// override if set, else <paramref name="setupDefault"/>. Used by the
/// draw dispatcher to pick the right cached mesh data per part.</summary>
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
=> TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
}

View file

@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) 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
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
/// atlas-tier (procedural) entities; this one handles per-instance-tier
/// (server-spawned). The boundary is <c>ServerGuid != 0</c> on
/// <see cref="WorldEntity"/>.
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). 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
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
/// Production callers supply a factory that fetches MotionTable from dats.
/// </para>
///
/// <para>
/// <b>Adjustment 4</b>: <see cref="WorldEntity"/> does not currently expose
/// <c>HiddenPartsMask</c> or <c>AnimPartChanges</c> as direct fields (those
/// live on the network-layer spawn record and are consumed upstream before
/// the <see cref="WorldEntity"/> is built). When those fields are promoted to
/// <see cref="WorldEntity"/>, <see cref="OnCreate"/> should call
/// <see cref="AnimatedEntityState.HideParts"/> and
/// <see cref="AnimatedEntityState.SetPartOverride"/> 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.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly Func<WorldEntity, AnimationSequencer> _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<uint, AnimatedEntityState> _stateByGuid = new();
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
/// entity. Receives the full <see cref="WorldEntity"/> so it can look up
/// the Setup + MotionTable from the entity's <c>SourceGfxObjOrSetupId</c>
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
Func<WorldEntity, AnimationSequencer> sequencerFactory)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_sequencerFactory = sequencerFactory;
}
/// <summary>
/// Process a server-spawned entity. Returns the created
/// <see cref="AnimatedEntityState"/> for the entity, or <c>null</c> if
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
/// </summary>
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;
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// </summary>
public void OnRemove(uint serverGuid) => _stateByGuid.Remove(serverGuid);
/// <summary>
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.
/// Returns <c>null</c> if the entity was never spawned or has already
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
}

View file

@ -0,0 +1,22 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -40,10 +40,14 @@ namespace AcDream.App.Streaming;
public sealed class GpuWorldState public sealed class GpuWorldState
{ {
private readonly LandblockSpawnAdapter? _wbSpawnAdapter; private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
public GpuWorldState(LandblockSpawnAdapter? wbSpawnAdapter = null) public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null)
{ {
_wbSpawnAdapter = wbSpawnAdapter; _wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
} }
private readonly Dictionary<uint, LoadedLandblock> _loaded = new(); private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
@ -246,6 +250,10 @@ public sealed class GpuWorldState
{ {
if (serverGuid == 0) return; 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; bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside. // Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
@ -301,6 +309,11 @@ public sealed class GpuWorldState
/// </summary> /// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity) 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; uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) if (_loaded.TryGetValue(canonicalLandblockId, out var lb))

View file

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

View file

@ -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<System.ArgumentNullException>(
() => 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;
}
}

View file

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

View file

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