diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fbf91c4..cf30c64 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -626,7 +626,17 @@ public sealed class GameWindow : IDisposable // the upright "Resting" pose instead of the Setup's Default // (T-pose / aggressive crouch). Static items with no motion table // get null and fall back to PlacementFrames in Flatten. - var idleFrame = AcDream.Core.Meshing.MotionResolver.GetIdleFrame(setup, _dats); + // Honor the server's CurrentMotionState (CreateObject MovementData) + // when present. The Foundry's drudge statue is the canonical case: + // its MotionTable's default style is upright "Ready" but the weenie + // is sent with a combat stance + Crouch ForwardCommand override, so + // resolving the cycle key from those gives the aggressive crouch. + ushort? stanceOverride = spawn.MotionState?.Stance; + ushort? commandOverride = spawn.MotionState?.ForwardCommand; + var idleFrame = AcDream.Core.Meshing.MotionResolver.GetIdleFrame( + setup, _dats, motionTableIdOverride: null, + stanceOverride: stanceOverride, + commandOverride: commandOverride); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame); // Apply the server's AnimPartChanges: "replace part at index N diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index da6cae2..43b7eba 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -90,7 +90,21 @@ public static class CreateObject IReadOnlyList SubPalettes, uint? BasePaletteId, float? ObjScale, - string? Name); + string? Name, + ServerMotionState? MotionState); + + /// + /// The relevant subset of the server-sent MovementData / + /// InterpretedMotionState: the entity's current stance + /// (MotionStance, e.g. NonCombat / HandCombat / Crouch) and its + /// active ForwardCommand (MotionCommand, e.g. Ready / Crouch / + /// AttackHigh). These are what we need to compose a MotionTable + /// cycle key (stance << 16) | (command & 0xFFFFFF) and + /// resolve the right idle frame for entities like the Foundry's + /// Nullified Statue of a Drudge, which is rendered in the wrong pose + /// if you only consult the MotionTable's default style. + /// + public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand); /// /// Server instruction to replace the surface texture at @@ -141,6 +155,7 @@ public static class CreateObject ServerPosition? position = null; uint? setupTableId = null; float? objScale = null; + ServerMotionState? motionState = null; try { @@ -219,16 +234,20 @@ public static class CreateObject if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0) { - // u32 length, length bytes of serialized MovementData, u32 isAutonomous flag + // u32 length, length bytes of serialized MovementData (no header + // — see ACE WorldObject_Networking.cs:326 writer.Write(movementData, false)), + // u32 isAutonomous (only present when the inner MovementData was non-empty). if (body.Length - pos < 4) return null; uint movementLen = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; if (movementLen > 0) { if (body.Length - pos < (int)movementLen) return null; - pos += (int)movementLen; + int movementStart = pos; + motionState = TryParseMovementData(body.Slice(movementStart, (int)movementLen)); + pos = movementStart + (int)movementLen; if (body.Length - pos < 4) return null; - pos += 4; // isAutonomous + pos += 4; // isAutonomous u32 } } else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0) @@ -327,13 +346,13 @@ public static class CreateObject } return new Parsed(guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, name); + textureChanges, subPalettes, basePaletteId, objScale, name, motionState); // Local helper: if we ran out of fields past PhysicsData, still - // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale). + // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). Parsed PartialResult() => new( guid, position, setupTableId, animParts, - textureChanges, subPalettes, basePaletteId, objScale, null); + textureChanges, subPalettes, basePaletteId, objScale, null, motionState); } catch { @@ -411,4 +430,85 @@ public static class CreateObject int padding = (4 - (pos & 3)) & 3; pos += padding; } + + /// + /// Parse the inner MovementData bytes (no header form, as written + /// by ACE's CreateObject path with writer.Write(movementData, false)). + /// We extract the CurrentStyle stance and, when MovementType is + /// Invalid (the typical case for stationary entities like the + /// Foundry's drudge statue), the InterpretedMotionState.ForwardCommand + /// motion command. Both are used by the renderer to compose a MotionTable + /// cycle key and resolve the entity's actual idle pose. + /// + /// Layout — see ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write + /// (header=false) and InterpretedMotionState.cs::Write: + /// + /// + /// u8 movementType + /// u8 motionFlags + /// u16 currentStyle (MotionStance) + /// For MovementType.Invalid (==0): InterpretedMotionState body + /// + /// Returns null on truncation; partial results are still returned with + /// whatever fields parsed successfully. + /// + private static ServerMotionState? TryParseMovementData(ReadOnlySpan mv) + { + try + { + int p = 0; + if (mv.Length < 4) return null; + byte movementType = mv[p]; p += 1; + byte _motionFlags = mv[p]; p += 1; + ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + p += 2; + + ushort? forwardCommand = null; + + // 0 = Invalid is the only union variant we care about for static + // entities. Walking/turning entities use the other variants but + // their forward command lives in InterpretedMotionState too; + // those are typed differently though, so be conservative. + if (movementType == 0) + { + // InterpretedMotionState: u32 (flags | numCommands<<7), then + // each present field in flag order. We only care about + // ForwardCommand, so read in order and stop early if we + // can't get that far. + if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null); + uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p)); + p += 4; + uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits + + // CurrentStyle (0x1) + if ((flags & 0x1u) != 0) + { + if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); + // The InterpretedMotionState's CurrentStyle is just a copy + // of MovementData.CurrentStyle per ACE source. Read and + // prefer it as the more specific value. + currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + p += 2; + } + + // ForwardCommand (0x2) + if ((flags & 0x2u) != 0) + { + if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null); + forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p)); + p += 2; + } + // Remaining fields (SideStep, Turn, speeds, commands list, + // align) are deliberately not parsed — we already have what + // the resolver needs and the outer length tells the caller + // where MovementData ends. + } + + return new ServerMotionState(currentStyle, forwardCommand); + } + catch + { + return null; + } + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 7de33b1..fc958c9 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -51,7 +51,8 @@ public sealed class WorldSession : IDisposable IReadOnlyList SubPalettes, uint? BasePaletteId, float? ObjScale, - string? Name); + string? Name, + CreateObject.ServerMotionState? MotionState); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -236,7 +237,8 @@ public sealed class WorldSession : IDisposable parsed.Value.SubPalettes, parsed.Value.BasePaletteId, parsed.Value.ObjScale, - parsed.Value.Name)); + parsed.Value.Name, + parsed.Value.MotionState)); } } } diff --git a/src/AcDream.Core/Meshing/MotionResolver.cs b/src/AcDream.Core/Meshing/MotionResolver.cs index 932d756..12c566d 100644 --- a/src/AcDream.Core/Meshing/MotionResolver.cs +++ b/src/AcDream.Core/Meshing/MotionResolver.cs @@ -53,7 +53,9 @@ public static class MotionResolver public static AnimationFrame? GetIdleFrame( Setup setup, DatCollection dats, - uint? motionTableIdOverride = null) + uint? motionTableIdOverride = null, + ushort? stanceOverride = null, + ushort? commandOverride = null) { ArgumentNullException.ThrowIfNull(setup); ArgumentNullException.ThrowIfNull(dats); @@ -64,20 +66,64 @@ public static class MotionResolver var mtable = dats.Get(mtableId); if (mtable is null) return null; - // Step 1: find the substate that DefaultStyle maps to. - if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate)) - return null; + // Resolve (style, substate) with priority: + // 1. Server-sent stance + command (CreateObject MovementData) — needed + // for entities like the Foundry's drudge statue, which override the + // MotionTable default with an aggressive crouch. + // 2. Server-sent stance only — substate falls back to that style's + // StyleDefaults entry. + // 3. MotionTable.DefaultStyle + StyleDefaults — the upright/Ready + // idle for everything else. + uint styleVal; + uint substateVal; - // Step 2: compose the cycle key. ACViewer's encoding: - // cycle = (DefaultStyle << 16) | (substate & 0xFFFFFF) - // Cast through uint then int because Cycles is keyed by int. - uint defaultStyleVal = (uint)mtable.DefaultStyle; - uint substateVal = (uint)defaultSubstate; - int cycleKey = (int)((defaultStyleVal << 16) | (substateVal & 0xFFFFFF)); + if (stanceOverride is { } stance && stance != 0) + { + styleVal = stance; + if (commandOverride is { } cmd && cmd != 0) + { + substateVal = cmd; + } + else if (mtable.StyleDefaults.TryGetValue((DatReaderWriter.Enums.MotionCommand)styleVal, out var subFromStyle)) + { + substateVal = (uint)subFromStyle; + } + else + { + return null; + } + } + else + { + if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate)) + return null; + styleVal = (uint)mtable.DefaultStyle; + substateVal = (uint)defaultSubstate; + } - if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null) - return null; - if (motionData.Anims.Count == 0) return null; + // ACViewer's cycle key encoding (Physics/Animation/MotionTable.cs:191): + // cycle = (style << 16) | (substate & 0xFFFFFF) + int cycleKey = (int)((styleVal << 16) | (substateVal & 0xFFFFFF)); + + // Try the server-supplied combo first; if it doesn't resolve, fall back + // to the table's default style + that style's default substate. This + // matters when the server sends a (stance, command) pair the table + // doesn't have a cycle entry for — better an upright pose than nothing. + if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null + || motionData.Anims.Count == 0) + { + if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var fallbackSub)) + { + int fallbackKey = (int)(((uint)mtable.DefaultStyle << 16) | ((uint)fallbackSub & 0xFFFFFF)); + if (!mtable.Cycles.TryGetValue(fallbackKey, out motionData) || motionData is null) + return null; + if (motionData.Anims.Count == 0) return null; + } + else + { + return null; + } + } var animData = motionData.Anims[0];