fix #270: run retail spawn placement at remote-body creation - standing monsters' attack animations restored

The [MT-FAIL] probe caught combat-stance monsters constantly failing to
dispatch 0x40000015 (Falling): their bodies were airborne-flagged while
standing. contact_allows_move (0x00528dd0) requires Contact+OnWalkable
and silently refuses every action animation for an airborne mover - a
spawned-standing monster's swings never played until it first moved.

Retail never has this state: CreateObject spawns run the placement
transition (CPhysicsObj::SetPosition -> SetPositionInternal 0x00515330),
which establishes CONTACT/ON_WALKABLE from the floor at spawn. Our
remote creation seeded a raw position with no placement.

SeedRemoteSpawnPlacement mirrors RemoteTeleportPlacement: engine
placement resolve (Setup-derived cylinder, TS-46) + the verbatim
CommitSetPositionTransition, wired at BOTH RemoteMotion creation sites
(UM-triggered creation - so a first-ever-UM attack animates in the same
packet - and ordinary first-UP creation). Unplaceable results leave the
body airborne exactly like a failed retail placement.

Also adds the [UM-ACT] (wire action items + stamp-gate verdict) and
[MT-FAIL] (refused animation dispatches) probes, riding
ACDREAM_DUMP_MOTION=1, which are what convicted the body state.

Complete Release suite: 10,032 passed / 5 skips / 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 23:28:57 +02:00
parent 1390add140
commit 4da25a442b
4 changed files with 257 additions and 17 deletions

View file

@ -148,6 +148,74 @@ internal sealed class LiveEntityNetworkUpdateController
private static bool IsDoorName(string? name) => name == "Door";
/// <summary>
/// #270 (2026-07-30): retail spawns run the placement transition
/// (<c>CPhysicsObj::SetPosition</c> → <c>SetPositionInternal</c>
/// 0x00515330), which establishes CONTACT/ON_WALKABLE from the floor the
/// creature stands on. A raw position seed leaves the fresh body
/// airborne-flagged, and <c>contact_allows_move</c> (0x00528dd0) then
/// silently refuses every action animation — a spawned-standing monster's
/// attack swings never played until it first moved (the [MT-FAIL]
/// Falling-substitution spam was the same body state surfacing through
/// <c>apply_interpreted_movement</c>). Mirrors
/// <see cref="RemoteTeleportPlacement"/>'s commit with spawn-shaped
/// inputs (no prior contact).
/// </summary>
private void SeedRemoteSpawnPlacement(
RemoteMotion remote,
uint serverGuid,
AcDream.Core.World.WorldEntity entity,
System.Numerics.Vector3 worldPos,
uint cellId)
{
if (cellId == 0)
return;
var (radius, height) = _motionRuntime.GetSetupCylinder(serverGuid, entity);
if (radius < 0.05f)
{
radius = 0.48f;
height = 1.835f;
}
ResolveResult placement = _physicsEngine.ResolvePlacement(
worldPos,
cellId,
radius,
height,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
moverFlags: IsPlayerGuid(serverGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
movingEntityId: entity.Id);
if (!placement.Ok)
return; // unplaceable — stays airborne, like a failed retail placement
remote.Body.Position = placement.Position;
remote.Body.ContactPlaneValid = placement.InContact;
if (placement.InContact)
{
remote.Body.ContactPlane = placement.ContactPlane;
remote.Body.ContactPlaneCellId = placement.ContactPlaneCellId;
remote.Body.ContactPlaneIsWater = placement.ContactPlaneIsWater;
remote.Body.GroundNormal = placement.ContactPlane.Normal;
}
AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition(
remote.Body,
placement.InContact,
placement.OnWalkable,
placement.CollisionNormalValid,
placement.CollisionNormal,
previousContact: false,
previousOnWalkable: false,
remote.Movement.HitGround,
remote.Motion.LeaveGround);
remote.Airborne = !remote.Body.OnWalkable;
}
private bool RunRemoteTeleportHook(
uint serverGuid,
uint localEntityId,
@ -652,6 +720,19 @@ internal sealed class LiveEntityNetworkUpdateController
update.Guid);
remote.Body.Orientation = entity.Rotation;
remote.Body.Position = entity.Position;
// #270: run the retail spawn placement so the fresh body has
// real ground contact BEFORE the funnel below dispatches this
// packet's actions — a first-ever-UM attack swing needs
// contact_allows_move true to animate.
SeedRemoteSpawnPlacement(
remote,
update.Guid,
entity,
entity.Position,
// Interior live entities carry ParentCellId; outdoor live
// entities carry the outdoor landcell in EffectCellId (see
// WorldEntity's cell-field docs). 0 → helper no-ops.
entity.ParentCellId ?? entity.EffectCellId ?? 0u);
}
if (!IsCurrentOwner(remote))
return default;
@ -1273,6 +1354,16 @@ internal sealed class LiveEntityNetworkUpdateController
// seed exactly like the UM path); worldPos == entity.Position (the
// unconditional snap at the top of this handler already ran).
rmState.Body.Position = worldPos;
// #270: retail spawn placement — establish real ground contact
// for the fresh body (a UP-created remote that then stands
// still would otherwise stay airborne-flagged and
// contact_allows_move would refuse its action animations).
SeedRemoteSpawnPlacement(
rmState,
update.Guid,
entity,
worldPos,
update.Position.LandblockId);
}
// PositionPack::UnPack initializes an absent velocity to zero;

View file

@ -2772,6 +2772,17 @@ public sealed class MotionInterpreter : IMotionDoneSink
int stored = ServerActionStamp & 0x7FFF;
int diff = incoming >= stored ? incoming - stored : stored - incoming;
bool newer = diff <= 0x3FFF ? stored < incoming : incoming < stored;
// #270 missing-attack investigation (2026-07-30): one line per
// wire action item with the gate verdict — rides
// ACDREAM_DUMP_MOTION=1. Strip when #270 closes.
if (PhysicsDiagnostics.DumpMotionEnabled)
{
Console.WriteLine(
$"[UM-ACT] cmd=0x{a.Command:X8} stamp={incoming} stored={stored} "
+ $"newer={newer} auton={a.Autonomous} localSkip={IsLocalPlayer && a.Autonomous}");
}
if (!newer) continue;
// Local player skips its own autonomous echoes (305977).
@ -3020,6 +3031,17 @@ public sealed class MotionInterpreter : IMotionDoneSink
// non-action" apply-only path below — but WITHOUT writing state.
bool dispatchOk = sink?.ApplyMotion(motion, p.Speed) ?? true;
// #270 missing-attack investigation (2026-07-30): surface FAILED
// animation dispatches — a silently-failing sink is the "monster
// attacks but the animation never fires" candidate. Rides
// ACDREAM_DUMP_MOTION=1. Strip when #270 closes.
if (!dispatchOk && PhysicsDiagnostics.DumpMotionEnabled)
{
Console.WriteLine(
$"[MT-FAIL] motion=0x{motion:X8} speed={p.Speed:F2} "
+ $"style=0x{InterpretedState.CurrentStyle:X8} substate=0x{InterpretedState.ForwardCommand:X8}");
}
if (!dispatchOk)
{
// Retail: `result = CPhysicsObj::DoInterpretedMotion(...)` is