diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index eb2d92c..3502b52 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -20,18 +20,39 @@ public readonly record struct MovementInput(
///
/// Result of a single frame's movement update.
+///
+///
+/// Wire vs. local animation command. ACE's MovementData
+/// (ACE.Server/Network/Motion/MovementData.cs) only computes
+/// interpState.ForwardSpeed for raw WalkForward/
+/// WalkBackwards — on every other command the else branch
+/// passes through command without setting speed, leaving observers with
+/// speed=0. The client therefore has to send WalkForward
+/// (with HoldKey.Run for running) and let ACE auto-upgrade to
+/// RunForward for broadcast. But the LOCAL view wants the run
+/// cycle immediately, so we carry a separate
+/// for the player's own renderer.
+///
+///
+/// — true when the player is holding Shift to run.
+/// Used by the GameWindow when building the outbound MoveToState's
+/// CURRENT_HOLD_KEY (2=Run) vs (1=None).
+///
///
public readonly record struct MovementResult(
Vector3 Position,
uint CellId,
bool IsOnGround,
bool MotionStateChanged,
- uint? ForwardCommand,
+ uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …)
uint? SidestepCommand,
uint? TurnCommand,
float? ForwardSpeed,
float? SidestepSpeed,
float? TurnSpeed,
+ bool IsRunning = false,
+ uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running)
+ bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
@@ -101,6 +122,11 @@ public sealed class PlayerMovementController
private float _jumpExtent;
private const float JumpChargeRate = 1.0f; // 0→1 over 1 second
+ // Airborne → grounded transition detection. Flipped on every frame where
+ // the body transitions from airborne to on-walkable; used by the GameWindow
+ // to drive the landing animation cycle.
+ private bool _wasAirborneLastFrame;
+
// Previous frame's motion commands for change detection.
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
@@ -120,7 +146,13 @@ public sealed class PlayerMovementController
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
- _weenie = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
+ // Default skills — tuned toward mid-retail feel (jump ≈ 3m at full charge,
+ // run rate ≈ 2.4x). Real characters' skills come from PlayerDescription
+ // (0xF7B0/0x0013) which we don't parse yet; override via env vars:
+ // ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
+ int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
+ int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 200;
+ _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
}
@@ -247,10 +279,11 @@ public sealed class PlayerMovementController
else if (input.Backward)
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
+ // Full-speed strafe to match retail sidestep pace.
if (input.StrafeRight)
- localX = MotionInterpreter.SidestepAnimSpeed * 0.5f;
+ localX = MotionInterpreter.SidestepAnimSpeed;
else if (input.StrafeLeft)
- localX = -MotionInterpreter.SidestepAnimSpeed * 0.5f;
+ localX = -MotionInterpreter.SidestepAnimSpeed;
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
@@ -307,6 +340,7 @@ public sealed class PlayerMovementController
// Apply resolved position.
_body.Position = resolveResult.Position;
+ bool justLanded = false;
if (resolveResult.IsOnGround)
{
if (_body.Velocity.Z <= 0f)
@@ -320,7 +354,10 @@ public sealed class PlayerMovementController
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
+ {
_motion.HitGround();
+ justLanded = true;
+ }
}
else
{
@@ -336,6 +373,7 @@ public sealed class PlayerMovementController
_body.calc_acceleration();
}
+ _wasAirborneLastFrame = !_body.OnWalkable;
CellId = resolveResult.CellId;
// ── 6. Determine outbound motion commands ─────────────────────────────
@@ -346,26 +384,52 @@ public sealed class PlayerMovementController
uint? outTurnCmd = null;
float? outTurnSpeed = null;
+ // Retail-faithful wire commands. ACE's MovementData constructor only
+ // computes interpState.ForwardSpeed for WalkForward / WalkBackwards
+ // (Network/Motion/MovementData.cs:104-119) — for any other command
+ // the else-branch passes through without setting speed, so observers
+ // dead-reckon at speed=0. The wire therefore must be:
+ // - Forward (walk): WalkForward @ 1.0
+ // - Forward (run): WalkForward @ run_rate + HoldKey.Run
+ // (ACE auto-upgrades to RunForward for observers)
+ // - Backward: WalkBackward @ 1.0
+ // Our own local animation still wants the actual RunForward cycle
+ // though — that's carried separately in LocalAnimationCommand below.
+ uint? localAnimCmd = null;
if (input.Forward)
{
- outForwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
- outForwardSpeed = 1.0f;
+ outForwardCmd = MotionCommand.WalkForward;
+ if (input.Run && _weenie.InqRunRate(out float runRate))
+ {
+ outForwardSpeed = runRate;
+ localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
+ }
+ else
+ {
+ outForwardSpeed = 1.0f;
+ localAnimCmd = MotionCommand.WalkForward;
+ }
}
else if (input.Backward)
{
- outForwardCmd = MotionCommand.WalkForward; // backward = WalkForward at negative speed
- outForwardSpeed = -0.65f;
+ outForwardCmd = MotionCommand.WalkBackward;
+ outForwardSpeed = 1.0f;
+ localAnimCmd = MotionCommand.WalkBackward;
}
+ // Strafe: retail uses speed=1.0 for SideStep (see holtburger
+ // common.rs::locomotion_command_for_state). 0.5 was our earlier guess
+ // and made strafing feel lethargic; the retail feel is full-speed
+ // sidestep matching the walk forward pace.
if (input.StrafeRight)
{
outSidestepCmd = MotionCommand.SideStepRight;
- outSidestepSpeed = 0.5f;
+ outSidestepSpeed = 1.0f;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
- outSidestepSpeed = 0.5f;
+ outSidestepSpeed = 1.0f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
@@ -419,6 +483,9 @@ public sealed class PlayerMovementController
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed,
+ IsRunning: input.Run && input.Forward,
+ LocalAnimationCommand: localAnimCmd,
+ JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 773c280..3182b07 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -704,6 +704,26 @@ public sealed class GameWindow : IDisposable
{
_liveSpawnReceived++;
+ // De-dup: the server re-sends CreateObject for the same guid in
+ // several situations (visibility refresh, landblock crossing,
+ // appearance update). Without cleanup the OLD copy remains in
+ // GpuWorldState + WorldGameState + _animatedEntities, so the
+ // renderer draws both copies overlapped — producing the
+ // "NPC clothing changes when I turn the camera" bug because the
+ // depth test arbitrates between overlapping duplicates each frame.
+ //
+ // For a respawn, drop the previous rendering state here before we
+ // build the new one. `_entitiesByServerGuid` is the canonical map,
+ // its value is the live WorldEntity we need to dispose.
+ if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
+ {
+ _worldState.RemoveEntityByServerGuid(spawn.Guid);
+ _worldGameState.RemoveById(existingEntity.Id);
+ _animatedEntities.Remove(existingEntity.Id);
+ // Physics collision registry entry is keyed by local id too.
+ _physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
+ }
+
// Log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
// is the critical one — we can grep the log for "Nullified Statue
@@ -2530,6 +2550,16 @@ public sealed class GameWindow : IDisposable
if (result.MotionStateChanged)
{
+ // HoldKey axis values — retail enum (holtburger types.rs HoldKey):
+ // Invalid = 0, None = 1, Run = 2
+ // Retail always sends CURRENT_HOLD_KEY (and uses the same
+ // value for every active per-axis hold key — see
+ // holtburger's build_motion_state_raw_motion_state).
+ // When the player is running forward, 2=Run; otherwise 1=None.
+ const uint HoldKeyNone = 1u;
+ const uint HoldKeyRun = 2u;
+ uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone;
+
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.MoveToState.Build(
gameActionSequence: seq,
@@ -2539,7 +2569,10 @@ public sealed class GameWindow : IDisposable
sidestepSpeed: result.SidestepSpeed,
turnCommand: result.TurnCommand,
turnSpeed: result.TurnSpeed,
- holdKey: result.ForwardCommand == 0x44000007u ? 1u : (uint?)null,
+ holdKey: axisHoldKey, // always present
+ forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null,
+ sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null,
+ turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
@@ -2998,8 +3031,19 @@ public sealed class GameWindow : IDisposable
// Determine the animation command: forward takes priority, then sidestep,
// then turn, then idle (Ready 0x41000003).
+ //
+ // Note: AC's Jump (0x2500003b) is an Action motion (mask 0x25000000),
+ // NOT a SubState cycle. Feeding it to the MotionTable resolver via
+ // SetCycle produces a failed cycle lookup — which mis-renders the
+ // character. Proper action playback needs a separate sequencer path
+ // that honors the motion table's action queue; that's deferred.
+ // For now the player stays in whatever cycle was active when they
+ // jumped (usually walk/run or Ready) — animation wise it's wrong but
+ // at least the character doesn't implode.
uint animCommand;
- if (result.ForwardCommand is { } fwd)
+ if (result.LocalAnimationCommand is { } localCmd)
+ animCommand = localCmd;
+ else if (result.ForwardCommand is { } fwd)
animCommand = fwd;
else if (result.SidestepCommand is { } ss)
animCommand = ss;
diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
index 45585ed..0034e50 100644
--- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
+++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
@@ -44,9 +44,28 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
private float[] _instanceBuffer = new float[256 * 16]; // start at 256 instances
// ── Instance grouping scratch ─────────────────────────────────────────────
- // Reused every frame to avoid per-frame allocation. Key = GfxObjId.
- // Value = InstanceGroup (list of InstanceEntry + buffer offset for this group).
- private readonly Dictionary _groups = new();
+ //
+ // Reused every frame to avoid per-frame allocation.
+ //
+ // **Group key = (GfxObjId, PaletteOverrideHash, SurfaceOverridesHash).**
+ //
+ // An earlier implementation grouped on GfxObjId alone and resolved
+ // the per-sub-mesh texture from the first instance in the group — which
+ // is fine for scenery where every tree shares the same palette, but
+ // utterly broken for NPCs: every humanoid uses the same base body
+ // GfxObjs and they all piled into one group, so the first NPC's palette
+ // was used for every NPC in the frame. Frustum culling + iteration
+ // order meant that "first NPC" changed as the camera turned — producing
+ // the "NPC clothing changes when I turn" symptom.
+ //
+ // Now we also key by the entity's PaletteOverride + per-MeshRef
+ // SurfaceOverrides signature so only entities that decode to the
+ // SAME texture for every sub-mesh can share a batch. Entities with
+ // unique appearance fall to single-instance groups (still correct,
+ // marginally slower than true instancing).
+ private readonly Dictionary _groups = new();
+
+ private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature);
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
@@ -179,9 +198,9 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
}
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
- foreach (var (gfxObjId, grp) in _groups)
+ foreach (var (key, grp) in _groups)
{
- if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes))
+ if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasOpaqueSubMesh = false;
@@ -244,9 +263,9 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
- foreach (var (gfxObjId, grp) in _groups)
+ foreach (var (key, grp) in _groups)
{
- if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes))
+ if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasTranslucentSubMesh = false;
@@ -351,6 +370,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
+ // Hash the entity's PaletteOverride once — shared by every
+ // MeshRef on this entity, so we compute it outside the loop.
+ ulong palHash = HashPaletteOverride(entity.PaletteOverride);
+
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId))
@@ -358,10 +381,18 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
var model = meshRef.PartTransform * entityRoot;
- if (!_groups.TryGetValue(meshRef.GfxObjId, out var group))
+ // Texture signature = palette hash ^ surface-overrides hash.
+ // Two instances can share a batch only when their ResolveTex
+ // would return identical handles for every sub-mesh — that
+ // means identical palette AND identical surface overrides.
+ ulong surfHash = HashSurfaceOverrides(meshRef.SurfaceOverrides);
+ ulong texSig = palHash ^ surfHash;
+ var key = new GroupKey(meshRef.GfxObjId, texSig);
+
+ if (!_groups.TryGetValue(key, out var group))
{
group = new InstanceGroup();
- _groups[meshRef.GfxObjId] = group;
+ _groups[key] = group;
}
group.Entries.Add(new InstanceEntry(model, entity, meshRef));
@@ -370,6 +401,40 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
}
}
+ private static ulong HashPaletteOverride(AcDream.Core.World.PaletteOverride? p)
+ {
+ if (p is null) return 0UL;
+ ulong h = 0xCBF29CE484222325UL;
+ const ulong prime = 0x100000001B3UL;
+ h = (h ^ p.BasePaletteId) * prime;
+ foreach (var sp in p.SubPalettes)
+ {
+ h = (h ^ sp.SubPaletteId) * prime;
+ h = (h ^ sp.Offset) * prime;
+ h = (h ^ sp.Length) * prime;
+ }
+ return h;
+ }
+
+ ///
+ /// Order-independent hash of a SurfaceOverrides dictionary. XOR of each
+ /// (key, value) pair keeps the result stable regardless of Dictionary
+ /// iteration order, so two instances whose override maps contain the
+ /// same pairs will hash identically.
+ ///
+ private static ulong HashSurfaceOverrides(IReadOnlyDictionary? overrides)
+ {
+ if (overrides is null || overrides.Count == 0) return 0UL;
+ ulong acc = 0UL;
+ foreach (var kvp in overrides)
+ {
+ ulong pair = ((ulong)kvp.Key << 32) | kvp.Value;
+ acc ^= pair;
+ }
+ // Fold with a prime so the zero case doesn't collide with "empty".
+ return (acc ^ 0xCBF29CE484222325UL) * 0x100000001B3UL;
+ }
+
// ── Matrix write ──────────────────────────────────────────────────────────
///
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index ed2466a..f1fb421 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -201,6 +201,50 @@ public sealed class GpuWorldState
return result;
}
+ ///
+ /// Remove every entity with the given from
+ /// all loaded landblocks AND all pending buckets, then rebuild the flat
+ /// view. Used by the live CreateObject handler to de-duplicate
+ /// when the server re-sends a spawn (visibility refresh, landblock
+ /// crossing, etc.). Without this, multiple copies of the same NPC
+ /// accumulate in the renderer, each with its own PaletteOverride
+ /// and MeshRefs — producing "NPC clothing flickers as I turn the
+ /// camera" because the depth test picks different duplicates frame-to-frame.
+ ///
+ /// Safe to call with a server guid that's not currently present — no-op.
+ ///
+ public void RemoveEntityByServerGuid(uint serverGuid)
+ {
+ if (serverGuid == 0) return;
+
+ bool rebuiltLoaded = false;
+
+ // Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
+ foreach (var kvp in _loaded.ToArray())
+ {
+ var lb = kvp.Value;
+ int foundCount = 0;
+ for (int i = 0; i < lb.Entities.Count; i++)
+ if (lb.Entities[i].ServerGuid == serverGuid) foundCount++;
+
+ if (foundCount == 0) continue;
+
+ var newList = new List(lb.Entities.Count - foundCount);
+ foreach (var e in lb.Entities)
+ if (e.ServerGuid != serverGuid) newList.Add(e);
+
+ _loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList);
+ rebuiltLoaded = true;
+ }
+
+ // Scrub pending buckets too — a duplicate CreateObject may arrive
+ // while the landblock is still loading.
+ foreach (var kvp in _pendingByLandblock)
+ kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
+
+ if (rebuiltLoaded) RebuildFlatView();
+ }
+
///
/// Append an entity to a specific landblock's slot. Used by the live
/// CreateObject path where the server spawns entities at a server-side
diff --git a/src/AcDream.Core.Net/Messages/MoveToState.cs b/src/AcDream.Core.Net/Messages/MoveToState.cs
index 5569d25..0835696 100644
--- a/src/AcDream.Core.Net/Messages/MoveToState.cs
+++ b/src/AcDream.Core.Net/Messages/MoveToState.cs
@@ -90,7 +90,10 @@ public static class MoveToState
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
- byte contactLongJump = 1)
+ byte contactLongJump = 1,
+ uint? forwardHoldKey = null,
+ uint? sidestepHoldKey = null,
+ uint? turnHoldKey = null)
{
var w = new PacketWriter(128);
@@ -101,14 +104,24 @@ public static class MoveToState
// --- RawMotionState ---
// Build the flags word. Command list length (bits 11-31) is always 0.
+ // Field order matches holtburger's RawMotionState::pack — for any axis
+ // where we send a COMMAND + SPEED, retail expects the matching
+ // *_HOLD_KEY to accompany them (see holtburger's
+ // build_motion_state_raw_motion_state). Without the per-axis hold
+ // keys the server gets the flags but can't classify the input as a
+ // continuously-held key, so other players see the character sliding
+ // forward without an animation cycle.
uint flags = 0u;
- if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
- if (forwardCommand.HasValue) flags |= FlagForwardCommand;
- if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
+ if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
+ if (forwardCommand.HasValue) flags |= FlagForwardCommand;
+ if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
+ if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
- if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
- if (turnCommand.HasValue) flags |= FlagTurnCommand;
- if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
+ if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
+ if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
+ if (turnCommand.HasValue) flags |= FlagTurnCommand;
+ if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
+ if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
@@ -116,13 +129,13 @@ public static class MoveToState
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
- // FlagForwardHoldKey (0x8): not sent
+ if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
- // FlagSidestepHoldKey (0x40): not sent
+ if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
- // FlagTurnHoldKey (0x200): not sent
+ if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (32 bytes) ---
diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs
index d304827..fdcd828 100644
--- a/src/AcDream.Core/Plugins/WorldGameState.cs
+++ b/src/AcDream.Core/Plugins/WorldGameState.cs
@@ -11,4 +11,12 @@ public sealed class WorldGameState : IGameState
/// Called by the host as each entity is hydrated.
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
+
+ ///
+ /// Remove any snapshot with the given local Id. Used when the
+ /// server re-sends CreateObject for an entity already known to
+ /// acdream — the host deletes the old snapshot before adding the new
+ /// one so plugins don't see stale duplicates.
+ ///
+ public void RemoveById(uint id) => _entities.RemoveAll(e => e.Id == id);
}