From 3308cddda7cd32d694a521651fe024c09a4ca91e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 15:01:32 +0200 Subject: [PATCH] fix(movement+anim+session): clothing dedup, motion wire format, jump-skill default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate fixes landed today, each addressing a specific bug the user observed during live play: 1. NPC clothing changes by camera angle (InstancedMeshRenderer) - Group key was (GfxObjId) only, so every humanoid NPC using the same body mesh piled into one instance group; only the first instance's texture was used for the entire DrawInstanced batch, so which NPC's palette "won" changed as frustum culling and iteration order shuffled entries. - Now keyed by (GfxObjId, PaletteHash ^ SurfaceOverridesHash) so only compatible instances batch; each unique appearance gets its own draw call. Perf hit is small (humanoid NPCs each emit one more draw call); visually every NPC is now stable. 2. GpuWorldState dedup on respawn - Server re-sends CreateObject for the same guid on visibility refresh / landblock crossing / appearance update. AppendLiveEntity was blindly appending each time, so GpuWorldState accumulated multiple copies of the same entity, each with its own PaletteOverride / MeshRefs. That alone wasn't the clothing bug (that was #1) but it would have caused other overlap problems downstream. - Added RemoveEntityByServerGuid + WorldGameState.RemoveById; OnLiveEntitySpawnedLocked calls both before creating the new entity so respawns replace cleanly. 3. Motion wire format — run animation sync with retail observers - ACE's MovementData constructor only computes interpState.ForwardSpeed on the WalkForward/WalkBackwards branch; every other ForwardCommand falls into `else` and passes through WITHOUT speed set, giving observers speed=0. Sending RunForward directly meant retail clients saw us "run in place" while position drifted forward. - Wire: always WalkForward + HoldKey.Run for running. ACE auto-upgrades to RunForward with creature.GetRunRate() for broadcast — correct command + correct speed at observers. - Added per-axis FORWARD_HOLD_KEY / SIDE_STEP_HOLD_KEY / TURN_HOLD_KEY so every active axis carries HoldKey.Run when running (matches holtburger's build_motion_state_raw_motion_state). - Added LocalAnimationCommand to MovementResult so our own client still plays the RunForward cycle locally while the wire stays WalkForward. Wire vs. local animation command are now decoupled. - Walk-backward wire command changed from WalkForward@-0.65 to WalkBackward@1.0 (holtburger pattern). - Strafe speed changed from 0.5 to 1.0 on wire AND local physics (matches retail sidestep pace). 4. Jump height default + env-var tuning - Default jumpSkill bumped from 100 → 200 (jump ≈ 3m at full charge, closer to retail feel for a mid-level character). - ACDREAM_RUN_SKILL and ACDREAM_JUMP_SKILL env vars now override the defaults so the user can tune per-character until we parse PlayerDescription and plumb real skill values through. 5. JustLanded signal on MovementResult - Tracks airborne→grounded transition so future animation code can fire the landing cycle when we land. Just a bool flag for now — no consumer yet (the proper action-queue path will use it). Not in this commit: jump animation itself. An earlier attempt to SetCycle(Jump=0x2500003b) fed an Action-type motion into the SubState cycle resolver, which produced a "torso" mis-render. Reverted. The proper fix is porting the retail motion action-queue semantics into AnimationSequencer — see docs/research/deepdives/r03-motion-animation.md for the spec. That's the next session's work. 470 tests pass, build clean. --- .../Input/PlayerMovementController.cs | 87 ++++++++++++++++--- src/AcDream.App/Rendering/GameWindow.cs | 48 +++++++++- .../Rendering/InstancedMeshRenderer.cs | 83 ++++++++++++++++-- src/AcDream.App/Streaming/GpuWorldState.cs | 44 ++++++++++ src/AcDream.Core.Net/Messages/MoveToState.cs | 33 ++++--- src/AcDream.Core/Plugins/WorldGameState.cs | 8 ++ 6 files changed, 272 insertions(+), 31 deletions(-) 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); }