Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 3 complete
This commit is contained in:
commit
d30fcb2eb0
11 changed files with 791 additions and 8 deletions
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
|
@ -8745,4 +8786,16 @@ public sealed class GameWindow : IDisposable
|
|||
_ => $"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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
67
src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
Normal file
67
src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
Normal 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;
|
||||
}
|
||||
152
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal file
152
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal 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<WorldEntity, AnimationSequencer></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;
|
||||
}
|
||||
22
src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs
Normal file
22
src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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<uint, LoadedLandblock> _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
|
|||
/// </summary>
|
||||
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))
|
||||
|
|
|
|||
62
tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
Normal file
62
tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
256
tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
Normal file
256
tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
56
tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
Normal file
56
tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue