fix(movement+anim+session): clothing dedup, motion wire format, jump-skill default
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.
This commit is contained in:
parent
d951304875
commit
3308cddda7
6 changed files with 272 additions and 31 deletions
|
|
@ -20,18 +20,39 @@ public readonly record struct MovementInput(
|
|||
|
||||
/// <summary>
|
||||
/// Result of a single frame's movement update.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
|
||||
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
|
||||
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
|
||||
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
|
||||
/// passes through command without setting speed, leaving observers with
|
||||
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
|
||||
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
|
||||
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
|
||||
/// cycle immediately, so we carry a separate
|
||||
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsRunning"/> — 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<uint, InstanceGroup> _groups = new();
|
||||
//
|
||||
// Reused every frame to avoid per-frame allocation.
|
||||
//
|
||||
// **Group key = (GfxObjId, PaletteOverrideHash, SurfaceOverridesHash).**
|
||||
//
|
||||
// An earlier implementation grouped on <c>GfxObjId</c> 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<GroupKey, InstanceGroup> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static ulong HashSurfaceOverrides(IReadOnlyDictionary<uint, uint>? 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -201,6 +201,50 @@ public sealed class GpuWorldState
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove every entity with the given <paramref name="serverGuid"/> from
|
||||
/// all loaded landblocks AND all pending buckets, then rebuild the flat
|
||||
/// view. Used by the live <c>CreateObject</c> 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 <c>PaletteOverride</c>
|
||||
/// and <c>MeshRefs</c> — 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.
|
||||
/// </summary>
|
||||
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<WorldEntity>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append an entity to a specific landblock's slot. Used by the live
|
||||
/// CreateObject path where the server spawns entities at a server-side
|
||||
|
|
|
|||
|
|
@ -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,13 +104,23 @@ 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 (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
|
||||
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
|
||||
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
|
||||
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) ---
|
||||
|
|
|
|||
|
|
@ -11,4 +11,12 @@ public sealed class WorldGameState : IGameState
|
|||
|
||||
/// <summary>Called by the host as each entity is hydrated.</summary>
|
||||
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Remove any snapshot with the given local <c>Id</c>. Used when the
|
||||
/// server re-sends <c>CreateObject</c> 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.
|
||||
/// </summary>
|
||||
public void RemoveById(uint id) => _entities.RemoveAll(e => e.Id == id);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue