From 4da25a442b991c0ac6ba74e7a9e40d5e15bbc47d Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 23:28:57 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 37 +++--- .../LiveEntityNetworkUpdateController.cs | 91 +++++++++++++ src/AcDream.Core/Physics/MotionInterpreter.cs | 22 ++++ .../Physics/MotionTableAttackDispatchProbe.cs | 124 ++++++++++++++++++ 4 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 tests/AcDream.App.Tests/Physics/MotionTableAttackDispatchProbe.cs 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::SetPositionSetPositionInternal + /// 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()); + } +}