phase(N.4) Tasks 16+18+19: AnimatedEntityState + AnimPartChange + HiddenParts

Per-entity render state for the per-instance rendering tier
(server-spawned characters / creatures / equipped items). Holds:
- partGfxObjOverrides: Dictionary<int, ulong> — 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 14:37:09 +02:00
parent 36f7a601c4
commit ce72c574e9
4 changed files with 230 additions and 0 deletions

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