feat(net+core): Phase 6.2 — honor server CurrentMotionState for idle pose
CreateObject's MovementData was being skipped past, so the renderer always fell back to the MotionTable's default style/substate. That's correct for most NPCs and characters but wrong for entities the server explicitly puts into a non-default stance — most visibly the Foundry's Nullified Statue of a Drudge, which the server sends with a combat stance + Crouch ForwardCommand override and which therefore rendered as an upright drudge instead of the aggressive crouched statue you see on the retail client. CreateObject.TryParse now extracts ServerMotionState (Stance + optional ForwardCommand) from the inner MovementData. The header=false layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the two fields the resolver needs are read; remaining InterpretedMotionState bytes are skipped via the outer length so we don't have to handle alignment of fields we don't care about. MotionResolver.GetIdleFrame now takes optional stanceOverride and commandOverride. Resolution priority is server-stance+command → server-stance + style-default substate → MotionTable default. If the composed cycle key doesn't resolve we fall back to the table default rather than returning null, so a partial server override never makes the entity worse than Phase 6.1. 160 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
090d265a4e
commit
120e801ecf
4 changed files with 181 additions and 23 deletions
|
|
@ -626,7 +626,17 @@ public sealed class GameWindow : IDisposable
|
||||||
// the upright "Resting" pose instead of the Setup's Default
|
// the upright "Resting" pose instead of the Setup's Default
|
||||||
// (T-pose / aggressive crouch). Static items with no motion table
|
// (T-pose / aggressive crouch). Static items with no motion table
|
||||||
// get null and fall back to PlacementFrames in Flatten.
|
// 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);
|
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame);
|
||||||
|
|
||||||
// Apply the server's AnimPartChanges: "replace part at index N
|
// Apply the server's AnimPartChanges: "replace part at index N
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,21 @@ public static class CreateObject
|
||||||
IReadOnlyList<SubPaletteSwap> SubPalettes,
|
IReadOnlyList<SubPaletteSwap> SubPalettes,
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name);
|
string? Name,
|
||||||
|
ServerMotionState? MotionState);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||||
|
/// <c>InterpretedMotionState</c>: the entity's current stance
|
||||||
|
/// (MotionStance, e.g. NonCombat / HandCombat / Crouch) and its
|
||||||
|
/// active <c>ForwardCommand</c> (MotionCommand, e.g. Ready / Crouch /
|
||||||
|
/// AttackHigh). These are what we need to compose a MotionTable
|
||||||
|
/// cycle key <c>(stance << 16) | (command & 0xFFFFFF)</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server instruction to replace the surface texture at
|
/// Server instruction to replace the surface texture at
|
||||||
|
|
@ -141,6 +155,7 @@ public static class CreateObject
|
||||||
ServerPosition? position = null;
|
ServerPosition? position = null;
|
||||||
uint? setupTableId = null;
|
uint? setupTableId = null;
|
||||||
float? objScale = null;
|
float? objScale = null;
|
||||||
|
ServerMotionState? motionState = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -219,16 +234,20 @@ public static class CreateObject
|
||||||
|
|
||||||
if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0)
|
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;
|
if (body.Length - pos < 4) return null;
|
||||||
uint movementLen = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
uint movementLen = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
if (movementLen > 0)
|
if (movementLen > 0)
|
||||||
{
|
{
|
||||||
if (body.Length - pos < (int)movementLen) return null;
|
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;
|
if (body.Length - pos < 4) return null;
|
||||||
pos += 4; // isAutonomous
|
pos += 4; // isAutonomous u32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
|
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
|
||||||
|
|
@ -327,13 +346,13 @@ public static class CreateObject
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Parsed(guid, position, setupTableId, animParts,
|
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
|
// 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(
|
Parsed PartialResult() => new(
|
||||||
guid, position, setupTableId, animParts,
|
guid, position, setupTableId, animParts,
|
||||||
textureChanges, subPalettes, basePaletteId, objScale, null);
|
textureChanges, subPalettes, basePaletteId, objScale, null, motionState);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -411,4 +430,85 @@ public static class CreateObject
|
||||||
int padding = (4 - (pos & 3)) & 3;
|
int padding = (4 - (pos & 3)) & 3;
|
||||||
pos += padding;
|
pos += padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the inner <c>MovementData</c> bytes (no header form, as written
|
||||||
|
/// by ACE's CreateObject path with <c>writer.Write(movementData, false)</c>).
|
||||||
|
/// We extract the <c>CurrentStyle</c> stance and, when MovementType is
|
||||||
|
/// <c>Invalid</c> (the typical case for stationary entities like the
|
||||||
|
/// Foundry's drudge statue), the <c>InterpretedMotionState.ForwardCommand</c>
|
||||||
|
/// motion command. Both are used by the renderer to compose a MotionTable
|
||||||
|
/// cycle key and resolve the entity's actual idle pose.
|
||||||
|
/// <para>
|
||||||
|
/// Layout — see ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write
|
||||||
|
/// (header=false) and InterpretedMotionState.cs::Write:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>u8 movementType</item>
|
||||||
|
/// <item>u8 motionFlags</item>
|
||||||
|
/// <item>u16 currentStyle (MotionStance)</item>
|
||||||
|
/// <item>For MovementType.Invalid (==0): InterpretedMotionState body</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns null on truncation; partial results are still returned with
|
||||||
|
/// whatever fields parsed successfully.
|
||||||
|
/// </summary>
|
||||||
|
private static ServerMotionState? TryParseMovementData(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@ public sealed class WorldSession : IDisposable
|
||||||
IReadOnlyList<CreateObject.SubPaletteSwap> SubPalettes,
|
IReadOnlyList<CreateObject.SubPaletteSwap> SubPalettes,
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name);
|
string? Name,
|
||||||
|
CreateObject.ServerMotionState? MotionState);
|
||||||
|
|
||||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||||
public event Action<EntitySpawn>? EntitySpawned;
|
public event Action<EntitySpawn>? EntitySpawned;
|
||||||
|
|
@ -236,7 +237,8 @@ public sealed class WorldSession : IDisposable
|
||||||
parsed.Value.SubPalettes,
|
parsed.Value.SubPalettes,
|
||||||
parsed.Value.BasePaletteId,
|
parsed.Value.BasePaletteId,
|
||||||
parsed.Value.ObjScale,
|
parsed.Value.ObjScale,
|
||||||
parsed.Value.Name));
|
parsed.Value.Name,
|
||||||
|
parsed.Value.MotionState));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,9 @@ public static class MotionResolver
|
||||||
public static AnimationFrame? GetIdleFrame(
|
public static AnimationFrame? GetIdleFrame(
|
||||||
Setup setup,
|
Setup setup,
|
||||||
DatCollection dats,
|
DatCollection dats,
|
||||||
uint? motionTableIdOverride = null)
|
uint? motionTableIdOverride = null,
|
||||||
|
ushort? stanceOverride = null,
|
||||||
|
ushort? commandOverride = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(setup);
|
ArgumentNullException.ThrowIfNull(setup);
|
||||||
ArgumentNullException.ThrowIfNull(dats);
|
ArgumentNullException.ThrowIfNull(dats);
|
||||||
|
|
@ -64,20 +66,64 @@ public static class MotionResolver
|
||||||
var mtable = dats.Get<MotionTable>(mtableId);
|
var mtable = dats.Get<MotionTable>(mtableId);
|
||||||
if (mtable is null) return null;
|
if (mtable is null) return null;
|
||||||
|
|
||||||
// Step 1: find the substate that DefaultStyle maps to.
|
// 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;
|
||||||
|
|
||||||
|
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))
|
if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
|
||||||
return null;
|
return null;
|
||||||
|
styleVal = (uint)mtable.DefaultStyle;
|
||||||
|
substateVal = (uint)defaultSubstate;
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: compose the cycle key. ACViewer's encoding:
|
// ACViewer's cycle key encoding (Physics/Animation/MotionTable.cs:191):
|
||||||
// cycle = (DefaultStyle << 16) | (substate & 0xFFFFFF)
|
// cycle = (style << 16) | (substate & 0xFFFFFF)
|
||||||
// Cast through uint then int because Cycles is keyed by int.
|
int cycleKey = (int)((styleVal << 16) | (substateVal & 0xFFFFFF));
|
||||||
uint defaultStyleVal = (uint)mtable.DefaultStyle;
|
|
||||||
uint substateVal = (uint)defaultSubstate;
|
|
||||||
int cycleKey = (int)((defaultStyleVal << 16) | (substateVal & 0xFFFFFF));
|
|
||||||
|
|
||||||
if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null)
|
// 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;
|
return null;
|
||||||
if (motionData.Anims.Count == 0) return null;
|
if (motionData.Anims.Count == 0) return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var animData = motionData.Anims[0];
|
var animData = motionData.Anims[0];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue