phase(N.4) Adjustment 6: add PartOverrides + HiddenPartsMask to WorldEntity

Resolves Adjustment 4 (Option A): WorldEntity now carries the server-
sent AnimPartChange data as PartOverrides and a HiddenPartsMask bitmask.
EntitySpawnAdapter.OnCreate populates AnimatedEntityState from these
fields at spawn time. GameWindow's CreateObject handler converts the
network-layer AnimPartChange records into lightweight PartOverride
structs.

This unblocks Task 22: the WbDrawDispatcher can now resolve per-part
GfxObj overrides and hidden-part suppression from entity state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 15:10:22 +02:00
parent 16a36dda8f
commit 5b4fd4b61d
4 changed files with 70 additions and 14 deletions

View file

@ -992,6 +992,31 @@ behavior (does the adapter call the cache with the right args?). The
decode-byte conformance is structural: same function = same output. decode-byte conformance is structural: same function = same output.
Mark Task 20 ✅ structurally; no separate test file. Mark Task 20 ✅ structurally; no separate test file.
### Adjustment 6 (2026-05-08): Resolved Adjustment 4 — Option A (fields on WorldEntity)
**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges`
plumbing decision to Task 22. Two options:
- **A**: add fields to `WorldEntity`, populate at spawn time
- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate`
**Decision: Option A.** Reasoning:
1. The data is already computed at spawn time in GameWindow's CreateObject
handler — adding two fields is a 4-line change.
2. Option B would spread network-layer types across the streaming subsystem,
violating the same separation-of-concerns principle as Adjustment 2.
3. The 0xF625 ObjDescEvent (appearance update) replays through the same
spawn path, so WorldEntity fields work automatically for hot-swap updates.
**Implementation:**
- `WorldEntity` gains `PartOverrides: IReadOnlyList<PartOverride>` (default
empty) and `HiddenPartsMask: ulong` (default 0).
- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct
in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`.
- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)`
and `state.SetPartOverride(...)` for each override.
- GameWindow's CreateObject handler builds the `PartOverride[]` from the
server-sent `AnimPartChanges` list.
### Task 6 (original — kept for history) ### Task 6 (original — kept for history)
**Files:** **Files:**

View file

@ -2407,6 +2407,19 @@ public sealed class GameWindow : IDisposable
SubPalettes: ranges); SubPalettes: ranges);
} }
AcDream.Core.World.PartOverride[] entityPartOverrides;
if (animPartChanges.Count == 0)
{
entityPartOverrides = Array.Empty<AcDream.Core.World.PartOverride>();
}
else
{
entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count];
for (int i = 0; i < animPartChanges.Count; i++)
entityPartOverrides[i] = new AcDream.Core.World.PartOverride(
animPartChanges[i].PartIndex, animPartChanges[i].NewModelId);
}
var entity = new AcDream.Core.World.WorldEntity var entity = new AcDream.Core.World.WorldEntity
{ {
Id = _liveEntityIdCounter++, Id = _liveEntityIdCounter++,
@ -2416,6 +2429,7 @@ public sealed class GameWindow : IDisposable
Rotation = rot, Rotation = rot,
MeshRefs = meshRefs, MeshRefs = meshRefs,
PaletteOverride = paletteOverride, PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
}; };
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(

View file

@ -39,15 +39,10 @@ namespace AcDream.App.Rendering.Wb;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Adjustment 4</b>: <see cref="WorldEntity"/> does not currently expose /// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
/// <c>HiddenPartsMask</c> or <c>AnimPartChanges</c> as direct fields (those /// carries <see cref="WorldEntity.PartOverrides"/> and
/// live on the network-layer spawn record and are consumed upstream before /// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
/// the <see cref="WorldEntity"/> is built). When those fields are promoted to /// both to the created <see cref="AnimatedEntityState"/>.
/// <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> /// </para>
/// </summary> /// </summary>
public sealed class EntitySpawnAdapter public sealed class EntitySpawnAdapter
@ -125,11 +120,10 @@ public sealed class EntitySpawnAdapter
var sequencer = _sequencerFactory(entity); var sequencer = _sequencerFactory(entity);
var state = new AnimatedEntityState(sequencer); var state = new AnimatedEntityState(sequencer);
// Adjustment 4 placeholder: when WorldEntity gains HiddenPartsMask + // Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
// AnimPartChanges fields, apply them here: state.HideParts(entity.HiddenPartsMask);
// state.HideParts(entity.HiddenPartsMask); foreach (var po in entity.PartOverrides)
// foreach (var apc in entity.AnimPartChanges) state.SetPartOverride(po.PartIndex, po.GfxObjId);
// state.SetPartOverride(apc.PartIndex, apc.NewModelId);
_stateByGuid[entity.ServerGuid] = state; _stateByGuid[entity.ServerGuid] = state;
return state; return state;

View file

@ -55,4 +55,27 @@ public sealed class WorldEntity
/// visible trunk, producing "partial passthrough" bugs. /// visible trunk, producing "partial passthrough" bugs.
/// </summary> /// </summary>
public float Scale { get; init; } = 1.0f; public float Scale { get; init; } = 1.0f;
/// <summary>
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
/// tier entities.
/// </summary>
public IReadOnlyList<PartOverride> PartOverrides { get; init; } = Array.Empty<PartOverride>();
/// <summary>
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
/// draw time. Sourced from the server's <c>CreateObject</c> record when
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
} }
/// <summary>
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
/// network-layer <c>CreateObject.AnimPartChange</c> type.
/// </summary>
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);