diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 9629ad78..3b74a38f 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -118,23 +118,26 @@ when the exhausted state (stamina == 0) transitions; skills/burden/stamina
still reach `PlayerWeenie` immediately (the next natural dispatch picks up
rate changes, exactly retail).
-**Monster attack misses — narrowed by the 2026-07-30 retest session
-(182k-line motion dump, 272 attack UMs):** the edge-drain theory is
-ACQUITTED (zero [remote-edge] lines — no remote ground edges fired at
-all), and the legacy stop-detector is dead code (the observation tracker
-has no reader). All 272 attack UMs dispatched (`SetCycle 0x400000D3`);
-171 completed visibly; 3 were killed by a Ready UM arriving almost
-immediately after the swing began. Residual hypothesis: our remote UM
-path HARD-SWAPS cycles on SetCycle, while retail transitions between
-cycles through the motion table's LINK animations — a quick
-Ready-after-attack in retail still plays the swing's follow-through via
-the attack→ready link chain; ours truncates it. Next step: audit the
-remote dispatch path (RemoteInboundMotionDispatch → sequencer) for link
-transitions vs hard SetCycle, against retail's unpack →
-apply_current_movement → motion-table link graph. User confirms casting
-is FIXED; attack animations are "still a bit buggy" (occasional misses,
-consistent with the ~1% fast-replace rate plus link-less truncation
-visibility).
+**Monster attack misses — ROOT CAUSE FOUND AND FIXED (2026-07-30, third
+session):** the [MT-FAIL] probe caught combat-stance monsters constantly
+failing to dispatch motion 0x40000015 = FALLING — their bodies were
+airborne-FLAGGED while standing on the ground. `contact_allows_move`
+(0x00528dd0) requires Contact+OnWalkable on the body and silently refuses
+every action animation for an "airborne" mover — a spawned-standing
+monster's attack swings never played until it first moved (movement →
+resolve → floor touch → contact). Retail never has this state: CreateObject
+spawns run the placement transition (`CPhysicsObj::SetPosition` →
+SetPositionInternal 0x00515330), which establishes contact at spawn; our
+remote creation seeded a raw position with no placement. Fix:
+`SeedRemoteSpawnPlacement` runs the engine placement resolve + the
+verbatim `CommitSetPositionTransition` at BOTH RemoteMotion creation sites
+(UM-triggered and first-UP), mirroring `RemoteTeleportPlacement`. Earlier
+theories eliminated en route: remote edge-drains (zero edges fired), the
+legacy stop-detector (dead code), cycle hard-swap (the funnel uses the full
+motion-table link machinery), 0x00D3 misread (= CastSpell, casters animate
+fine), motion-table port (offline sweep: all 27 "failures" are non-caster
+tables never sent CastSpell). Probes [UM-ACT]/[MT-FAIL]/[remote-edge]
+remain in place (ride ACDREAM_DUMP_MOTION=1) until the user gate passes.
---
## #269 — Slope-stop slide runs too far (post-bounce-rework residual)
diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
index f68eadc0..d71387ea 100644
--- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
+++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
@@ -148,6 +148,74 @@ internal sealed class LiveEntityNetworkUpdateController
private static bool IsDoorName(string? name) => name == "Door";
+ ///
+ /// #270 (2026-07-30): retail spawns run the placement transition
+ /// (CPhysicsObj::SetPosition → SetPositionInternal
+ /// 0x00515330), which establishes CONTACT/ON_WALKABLE from the floor the
+ /// creature stands on. A raw position seed leaves the fresh body
+ /// airborne-flagged, and contact_allows_move (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
+ /// apply_interpreted_movement). Mirrors
+ /// 's commit with spawn-shaped
+ /// inputs (no prior contact).
+ ///
+ 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;
diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs
index 4cb9eba7..a176564b 100644
--- a/src/AcDream.Core/Physics/MotionInterpreter.cs
+++ b/src/AcDream.Core/Physics/MotionInterpreter.cs
@@ -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
diff --git a/tests/AcDream.App.Tests/Physics/MotionTableAttackDispatchProbe.cs b/tests/AcDream.App.Tests/Physics/MotionTableAttackDispatchProbe.cs
new file mode 100644
index 00000000..2b14baea
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/MotionTableAttackDispatchProbe.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using Xunit;
+
+namespace AcDream.App.Tests.Physics;
+
+// THROWAWAY probe (#270): sweep every MotionTable in the installed portal
+// dat and attempt the EXACT wire dispatch observed in the stuck-attack
+// session (style 0x8000003C, ready substate, target attack 0x400000D3 at
+// speed 2.0). A silent GetObjectSequence failure = "monster attacks but the
+// animation never fires". Delete after #270 closes.
+public sealed class MotionTableAttackDispatchProbe
+{
+ private sealed class NullLoader : AcDream.Core.Physics.IAnimationLoader
+ {
+ public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
+ }
+
+ [Fact]
+ public void Sweep_attack_dispatch_across_all_motion_tables()
+ {
+ var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ if (!Directory.Exists(datDir)) return;
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var loader = new NullLoader();
+
+ int total = 0, okCount = 0;
+ var failures = new List();
+ var styleMisses = new List();
+ foreach (var entry in dats.Portal.Tree.GetFilesInRange(0x09000000u, 0x09FFFFFFu))
+ {
+ if (!dats.Portal.TryGet(entry.Id, out var mt)
+ || mt is null)
+ continue;
+ total++;
+
+ var cmt = new CMotionTable(mt);
+ var state = new MotionState
+ {
+ Style = 0x8000003Cu,
+ Substate = 0x41000003u,
+ };
+ var seq = new CSequence(loader);
+
+ // Seed exactly like the live path: install the combat style +
+ // ready substate first (SetDefaultState equivalent would use the
+ // table default; the wire showed the monsters already standing in
+ // style 0x3C ready).
+ bool ok;
+ try
+ {
+ ok = cmt.GetObjectSequence(0x400000D3u, state, seq, 2.0f, out _, stopCall: false);
+ }
+ catch (Exception ex)
+ {
+ failures.Add($"0x{entry.Id:X8} THREW {ex.GetType().Name}");
+ continue;
+ }
+
+ if (ok) okCount++;
+ else
+ {
+ // Distinguish "table has no 0x3C style at all" (never a
+ // combat monster) from "style exists but dispatch failed".
+ bool hasStyle = mt.StyleDefaults.ContainsKey(
+ (DatReaderWriter.Enums.MotionCommand)0x8000003Cu);
+ if (hasStyle)
+ failures.Add($"0x{entry.Id:X8} FAILED (has 0x3C style)");
+ else
+ styleMisses.Add($"0x{entry.Id:X8}");
+ }
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"total={total} ok={okCount} failedWithStyle={failures.Count} noStyle={styleMisses.Count}");
+ foreach (var f in failures) sb.AppendLine(f);
+
+ // For the first few failing tables: dump what IS authored for the
+ // CastSpell substate (0xD3) so "un-authored data" vs "lookup bug"
+ // is decidable offline.
+ int dumped = 0;
+ foreach (var f in failures)
+ {
+ if (dumped >= 3 || !f.Contains("FAILED")) continue;
+ uint id = Convert.ToUInt32(f.Substring(2, 8), 16);
+ if (!dats.Portal.TryGet(id, out var mt) || mt is null)
+ continue;
+ dumped++;
+ sb.AppendLine($"--- table 0x{id:X8} DefaultStyle=0x{(uint)mt.DefaultStyle:X8}");
+ foreach (var kv in mt.Cycles)
+ {
+ uint key = (uint)kv.Key;
+ if ((key & 0xFFFFFFu) == 0xD3u || (key & 0xFFFFu) == 0xD3u)
+ sb.AppendLine($" cycle key=0x{key:X8}");
+ }
+ foreach (var kv in mt.Links)
+ {
+ uint key = (uint)kv.Key;
+ sb.AppendLine($" linkFrom key=0x{key:X8} targets={kv.Value.MotionData.Count}");
+ foreach (var t in kv.Value.MotionData.Keys)
+ {
+ uint tk = (uint)t;
+ if ((tk & 0xFFFFFFu) == 0xD3u)
+ sb.AppendLine($" -> target 0x{tk:X8} (CASTSPELL)");
+ }
+ }
+ }
+
+ File.WriteAllText(
+ Path.Combine(AppContext.BaseDirectory, "attack-dispatch-sweep.txt"),
+ sb.ToString());
+ var outCopy = Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUT");
+ if (!string.IsNullOrEmpty(outCopy))
+ File.WriteAllText(outCopy, sb.ToString());
+ }
+}