From ce72c574e929c6b2e42c2c646f521984acc8c9c6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 14:37:09 +0200 Subject: [PATCH] phase(N.4) Tasks 16+18+19: AnimatedEntityState + AnimPartChange + HiddenParts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-entity render state for the per-instance rendering tier (server-spawned characters / creatures / equipped items). Holds: - partGfxObjOverrides: Dictionary — AnimPartChange swaps (e.g. wielding a weapon replaces a hand-part's GfxObj). - hiddenMask: ulong — HiddenParts bitmask. Bit i set hides part i. - AnimationSequencer reference — N.4 doesn't touch the sequencer; this just exposes it for the draw dispatcher. Public API: HideParts / IsPartHidden / SetPartOverride / TryGetPartOverride / ResolvePartGfxObj. Bounds-checked (partIdx < 0 or >= 64 → IsPartHidden returns false). Twelve tests covering the type, the AnimPartChange resolution helper, and the HiddenParts bitmask edge cases (theories for 0b0/0b1/MSB/all-ones, plus negative-index + out-of-range guards). Consumed by Task 17's EntitySpawnAdapter (creates one per CreateObject) and Task 22's WbDrawDispatcher (reads via per-part draw loop). Co-Authored-By: Claude Opus 4.6 --- .../Rendering/Wb/AnimatedEntityState.cs | 67 +++++++++++++++++++ .../Rendering/Wb/AnimPartChangeTests.cs | 62 +++++++++++++++++ .../Rendering/Wb/AnimatedEntityStateTests.cs | 45 +++++++++++++ .../Rendering/Wb/HiddenPartsTests.cs | 56 ++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/AnimatedEntityStateTests.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs 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/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/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; + } +}