Three unpack_movement parity items (facade deferred per the handoff's own optional clause — see r5-wiring-handoff §V4 status): 1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow routing heads (remote + player) now dispatch DoMotion(style, ctor defaults) when the UM's stance differs from the interp's current style, BEFORE the movement-type routing — for EVERY type. Previously style applied only through the mt-0 funnel copy, so a chase/turn UM (mt 6-9) carrying a stance change started the move in the OLD stance (a monster charged in NonCombat posture until the next mt-0). The RetailObserverTraceConformanceTests exclusion note updated — the trace filter stays (head calls can't appear in a MoveToInterpretedState replay) but the production gap it pointed at is closed. 2. #164 (closes): the action-replay loop threads each action's autonomy into the dispatch params (Autonomous = the 0x1000 splice, raw 305982) — replayed actions enter the interpreted actions list with their real autonomy instead of ctor-default false. 3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3): 0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0: resolve target, PartArray radii — 0 when shapeless, guid-as-is for acdream's flat entity table — → PositionManager.StickTo; unresolvable target → no stick), at BOTH case-0 tails in retail order (@00524583-0052458e: funnel apply → stick → longjump flag); 0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write (absent flag clears — retail @0052458e). ServerMotionState gains the StandingLongJump field. Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase (harness mirrors the routing-head dispatch) + Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
191 lines
8.6 KiB
C#
191 lines
8.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// L.2g S2 conformance harness, layer 1: golden fixtures parsed from a LIVE
|
|
/// cdb trace of a RETAIL observer client
|
|
/// (Fixtures/l2g-observer-trace.log, captured 2026-07-02 with
|
|
/// tools/cdb/l2g-observer.cdb while a retail actor ran the structured
|
|
/// motion protocol through ACE).
|
|
///
|
|
/// Each [MTIS] block is an exact INPUT (the InterpretedMotionState retail
|
|
/// received, dumped field-by-field) and the [DIM] lines that follow on the
|
|
/// same minterp are retail's exact OUTPUT (every DoInterpretedMotion
|
|
/// dispatch, in order). We replay every input through acdream's funnel and
|
|
/// assert an identical dispatch sequence — retail's actual runtime is the
|
|
/// oracle, not anyone's reading of the decomp.
|
|
///
|
|
/// Exclusions (documented, not silent):
|
|
/// - Action-class dispatches (0x10000000 bit) come from the UM's action
|
|
/// LIST, which the [MTIS] struct dump doesn't include (LList pointer
|
|
/// only) — filtered from both sides; covered by the synthetic action
|
|
/// tests in MotionInterpreterFunnelTests instead.
|
|
/// - motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0])
|
|
/// style call for wire style index 0) — outside
|
|
/// move_to_interpreted_state, so it can never appear in a
|
|
/// MoveToInterpretedState replay; the filter stays for that trace-
|
|
/// mechanics reason. The PRODUCTION gap the old note pointed at ("S3
|
|
/// wires the unpack-level style-on-change") CLOSED in R5-V4: the
|
|
/// GameWindow routing heads dispatch DoMotion(style) on change for every
|
|
/// movement type (unpack_movement @00524502-0052452c; conformance:
|
|
/// RemoteChaseEndToEndHarnessTests.ChaseArm_WithStanceChange_
|
|
/// AppliesStanceBeforeTheChase).
|
|
/// </summary>
|
|
public class RetailObserverTraceConformanceTests
|
|
{
|
|
private sealed record TraceCase(
|
|
int Line, string Minterp, InboundInterpretedState Ims, List<uint> Dims);
|
|
|
|
private sealed class RecordingSink : IInterpretedMotionSink
|
|
{
|
|
public readonly List<uint> Applied = new();
|
|
public bool ApplyMotion(uint motion, float speed)
|
|
{
|
|
Applied.Add(motion);
|
|
// R3-W5: style/stance ids (>= 0x80000000) have no dat MotionData
|
|
// entry — retail's real motion-table lookup fails for them
|
|
// (see MotionInterpreterDoMotionFamilyTests / the interface
|
|
// doc on IInterpretedMotionSink.ApplyMotion for the full
|
|
// rationale). The fake sink mirrors that so the style dispatch
|
|
// doesn't clobber ForwardCommand before the next read.
|
|
return motion < 0x80000000u;
|
|
}
|
|
public bool StopMotion(uint motion) => true;
|
|
}
|
|
|
|
private static string TracePath()
|
|
{
|
|
var dir = AppContext.BaseDirectory;
|
|
while (dir is not null && !File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
|
dir = Directory.GetParent(dir)?.FullName;
|
|
Assert.NotNull(dir);
|
|
return Path.Combine(dir!, "tests", "AcDream.Core.Tests", "Fixtures", "l2g-observer-trace.log");
|
|
}
|
|
|
|
private static List<TraceCase> ParseTrace()
|
|
{
|
|
var lines = File.ReadAllLines(TracePath());
|
|
var cases = new List<TraceCase>();
|
|
var mtisRe = new Regex(@"\[MTIS\] minterp=(\w+)");
|
|
var fieldRe = new Regex(@"\+0x(\w+) (\w+)\s+: (\S+)");
|
|
var dimRe = new Regex(@"\[DIM\] minterp=(\w+) motion=(\w+)");
|
|
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
var m = mtisRe.Match(lines[i]);
|
|
if (!m.Success) continue;
|
|
string minterp = m.Groups[1].Value;
|
|
|
|
var ims = InboundInterpretedState.Default();
|
|
int j = i + 1;
|
|
for (; j < lines.Length; j++)
|
|
{
|
|
var f = fieldRe.Match(lines[j]);
|
|
if (!f.Success) break;
|
|
string name = f.Groups[2].Value;
|
|
string val = f.Groups[3].Value;
|
|
uint ParseHex() => Convert.ToUInt32(val, 16);
|
|
float ParseF() => float.Parse(val, CultureInfo.InvariantCulture);
|
|
switch (name)
|
|
{
|
|
case "current_style": ims.CurrentStyle = ParseHex(); break;
|
|
case "forward_command": ims.ForwardCommand = ParseHex(); break;
|
|
case "forward_speed": ims.ForwardSpeed = ParseF(); break;
|
|
case "sidestep_command": ims.SideStepCommand = ParseHex(); break;
|
|
case "sidestep_speed": ims.SideStepSpeed = ParseF(); break;
|
|
case "turn_command": ims.TurnCommand = ParseHex(); break;
|
|
case "turn_speed": ims.TurnSpeed = ParseF(); break;
|
|
}
|
|
}
|
|
|
|
// Collect this minterp's DIMs until the next UNPACK/MTIS event.
|
|
var dims = new List<uint>();
|
|
for (; j < lines.Length; j++)
|
|
{
|
|
if (lines[j].Contains("[UNPACK]") || lines[j].Contains("[MTIS]")) break;
|
|
var d = dimRe.Match(lines[j]);
|
|
if (d.Success && d.Groups[1].Value == minterp)
|
|
dims.Add(Convert.ToUInt32(d.Groups[2].Value, 16));
|
|
}
|
|
|
|
// A SECOND style dispatch marks a second apply_current_movement
|
|
// pass fired by the physics tick (HitGround / LeaveGround
|
|
// re-apply, CMotionInterp::HitGround 0x00528ac0) — not by this
|
|
// UM. Keep only the first pass as this UM's output. (2 cases in
|
|
// the capture — the actor's jump/land moments.)
|
|
int secondStyle = -1;
|
|
for (int k = 1; k < dims.Count; k++)
|
|
if (dims[k] == ims.CurrentStyle) { secondStyle = k; break; }
|
|
if (secondStyle > 0) dims = dims.Take(secondStyle).ToList();
|
|
|
|
cases.Add(new TraceCase(i + 1, minterp, ims, dims));
|
|
}
|
|
return cases;
|
|
}
|
|
|
|
private static bool IsExcluded(uint motion)
|
|
=> (motion & 0x10000000u) != 0 // action-list dispatches, not in the dump
|
|
|| motion == 0x80000000u; // unpack-level style-index-0 call
|
|
|
|
[Fact]
|
|
public void EveryTracedUm_ProducesRetailsExactDispatchSequence()
|
|
{
|
|
var cases = ParseTrace();
|
|
Assert.True(cases.Count > 100, $"expected >100 traced UMs, parsed {cases.Count}");
|
|
|
|
int verified = 0;
|
|
var failures = new List<string>();
|
|
foreach (var c in cases)
|
|
{
|
|
// The trace breakpoint sits at DoInterpretedMotion ENTRY
|
|
// (pre-gate); the sink records POST-gate (GetObjectSequence)
|
|
// calls. For grounded bodies they coincide. A Falling dispatch
|
|
// for a non-Falling forward command marks the traced body as
|
|
// AIRBORNE at that moment (the actor's jump) — replay those
|
|
// with contact cleared, and drop the traced style entry from
|
|
// the expectation (called pre-gate, blocked post-gate).
|
|
bool airborne = c.Ims.ForwardCommand != 0x40000015u
|
|
&& c.Dims.Contains(0x40000015u);
|
|
|
|
var body = new PhysicsBody();
|
|
body.State |= PhysicsStateFlags.Gravity;
|
|
body.TransientState |= TransientStateFlags.Active;
|
|
if (!airborne)
|
|
body.TransientState |= TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable;
|
|
var mi = new MotionInterpreter(body);
|
|
var sink = new RecordingSink();
|
|
|
|
mi.MoveToInterpretedState(c.Ims, sink);
|
|
|
|
var expected = c.Dims.Where(d => !IsExcluded(d)
|
|
&& (!airborne || d != c.Ims.CurrentStyle)).ToList();
|
|
var actual = sink.Applied.Where(d => !IsExcluded(d)).ToList();
|
|
if (!expected.SequenceEqual(actual))
|
|
{
|
|
failures.Add(
|
|
$"line {c.Line} minterp {c.Minterp}: " +
|
|
$"fwd=0x{c.Ims.ForwardCommand:X8}@{c.Ims.ForwardSpeed:F2} " +
|
|
$"side=0x{c.Ims.SideStepCommand:X8} turn=0x{c.Ims.TurnCommand:X8} — " +
|
|
$"retail [{string.Join(",", expected.Select(d => d.ToString("X8")))}] " +
|
|
$"vs acdream [{string.Join(",", actual.Select(d => d.ToString("X8")))}]");
|
|
}
|
|
else
|
|
{
|
|
verified++;
|
|
}
|
|
}
|
|
|
|
Assert.True(failures.Count == 0,
|
|
$"{failures.Count} of {cases.Count} traced UMs diverged "
|
|
+ $"({verified} conformant):\n" + string.Join("\n", failures.Take(12)));
|
|
}
|
|
}
|