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:
parent
1390add140
commit
4da25a442b
4 changed files with 257 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
var styleMisses = new List<string>();
|
||||
foreach (var entry in dats.Portal.Tree.GetFilesInRange(0x09000000u, 0x09FFFFFFu))
|
||||
{
|
||||
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.MotionTable>(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<DatReaderWriter.DBObjs.MotionTable>(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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue