feat(L.1b): dual MotionCommand catalog — AceModern runtime + Retail2013 conformance
Splits MotionCommandResolver's single ACE-modern lookup into an IMotionCommandCatalog seam with two implementations: - AceModernCommandCatalog (runtime default): built cleanly from the DatReaderWriter MotionCommand enum (mirrors ACE + local DAT MotionTables) with a documented class-priority tiebreak. The old blind 0x016E-0x0197 per-range override is DELETED — verified the ACE matrix (LifestoneRecall 0x0153 to 0x10000153, MarketplaceRecall 0x0166, AllegianceHometownRecall 0x0171, OffhandSlashHigh 0x0173) resolves correctly straight from the enum with no override. Stale shift-start comment corrected to SnowAngelState (0x43000115 to 0x43000118), not AllegianceHometownRecall. - Retail2013CommandCatalog (conformance/reference): full verbatim extraction of command_ids[0x198] at 0x007c73e8 (acclient_2013_pseudo_c.txt:1017259-1017667), direct wire-low to full index lookup. 408 entries, anchors verified against source ([0x150]=0x10000150, [0x153]=0x09000153, [0x197]=0x10000197). MotionCommandResolver.ReconstructFullCommand stays a static facade delegating to an AceModern singleton — all ~10 runtime callers unchanged. Removing the override exposed a pre-existing bug: CombatAnimationPlanner's late-combat command block uses 2013 numbering, not ACE/DRW. Corrected the one catalog test assertion pinned to the override's output (wire 0x0170 to 0x09000170 IssueSlashCommand, its true DRW identity) and filed the planner bug as #159 rather than silently patching out-of-scope code. Tests: +catalog matrices (ACE/2013), class-priority collisions, boundary cases, real-DAT availability (gap-doc hit counts reproduced). Build + full suite green (Core.Tests 1718, no regressions). Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (1) Research: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
33ad231d18
commit
2c8620ea94
8 changed files with 612 additions and 65 deletions
|
|
@ -30,11 +30,35 @@ public sealed class CombatAnimationPlannerTests
|
|||
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
|
||||
}
|
||||
|
||||
// L.1b correction (2026-06-30): these four expected values were pinned
|
||||
// against MotionCommandResolver's old blind per-range override
|
||||
// (0x016E-0x0197 force-mapped to class 0x10000000), not against named
|
||||
// retail truth. The override is gone — MotionCommandResolver now
|
||||
// delegates to AceModernCommandCatalog, built cleanly from
|
||||
// DatReaderWriter.Enums.MotionCommand (Chorizite.DatReaderWriter
|
||||
// 2.1.7). Per that enum, wire 0x0170 is IssueSlashCommand = 0x09000170
|
||||
// (class 0x09, UI command) — there is no Action-class (0x10) entry at
|
||||
// that wire at all, so the old "OffhandSlashHigh" expectation here was
|
||||
// simply wrong (real OffhandSlashHigh = 0x10000173, see
|
||||
// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md). Updated
|
||||
// to the wire's real DRW-enum identity.
|
||||
//
|
||||
// NOTE: the surviving rows (0x017D/0x018B/0x018E) still pass, but only
|
||||
// because they land on SOME Action-class value at that low word — their
|
||||
// inline comments ("OffhandDoubleThrustMed"/"AttackLow6"/"PunchFastLow")
|
||||
// were also wrong names (the real DRW names are
|
||||
// OffhandTripleSlashMed/AttackLow5/AttackLow6 respectively). This
|
||||
// reveals that CombatAnimationPlanner.CombatAnimationMotionCommands
|
||||
// (src/AcDream.Core/Combat/CombatAnimationPlanner.cs:268-307) is itself
|
||||
// built from 2013-decomp numbering, not ACE/DRW numbering — a
|
||||
// pre-existing, separate bug outside this slice's scope (catalog only;
|
||||
// CombatAnimationPlanner is explicitly out of bounds for L.1b). Flagged
|
||||
// for follow-up rather than silently patched.
|
||||
[Theory]
|
||||
[InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh
|
||||
[InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed
|
||||
[InlineData(0x018B, 0x1000018Bu)] // AttackLow6
|
||||
[InlineData(0x018E, 0x1000018Eu)] // PunchFastLow
|
||||
[InlineData(0x0170, 0x09000170u)] // IssueSlashCommand (UI class) — see note above
|
||||
[InlineData(0x017D, 0x1000017Du)] // OffhandTripleSlashMed
|
||||
[InlineData(0x018B, 0x1000018Bu)] // AttackLow5
|
||||
[InlineData(0x018E, 0x1000018Eu)] // AttackLow6
|
||||
public void MotionCommandResolver_UsesNamedRetailLateCombatCommands(
|
||||
ushort wireCommand,
|
||||
uint expectedFullCommand)
|
||||
|
|
|
|||
130
tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs
Normal file
130
tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System.Linq;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Tests.Conformance;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Real-DAT availability tests for the L.1b dual command catalog. A command
|
||||
/// only actually ANIMATES if the entity's <c>MotionTable</c> has a
|
||||
/// <c>Links</c> or <c>Modifiers</c> entry for the full 32-bit value — an
|
||||
/// enum/table value alone proves nothing about whether the local DATs will
|
||||
/// ever play it.
|
||||
///
|
||||
/// <para>
|
||||
/// Reproduces the scan from
|
||||
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> as
|
||||
/// pinned assertions: a command "exists" for a MotionTable when its full
|
||||
/// 32-bit value appears as either (a) an outer <c>Links</c> key's low 16
|
||||
/// bits (the from-state -> command transition key), (b) an inner
|
||||
/// <c>Links[...].MotionData</c> key (the actual animation-bearing target),
|
||||
/// or (c) a <c>Modifiers</c> key. This three-way check is what reproduces
|
||||
/// the gap doc's hit counts exactly (verified against a live scan of the
|
||||
/// local <c>client_portal.dat</c>: LifestoneRecall/ACE=19,
|
||||
/// MarketplaceRecall/ACE=19, AllegianceHometownRecall/ACE=19,
|
||||
/// OffhandSlashHigh/ACE=31, HouseRecall/ACE=24 tables; all four
|
||||
/// 2013-numbered equivalents=0 tables; HouseRecall/2013=42 tables, i.e.
|
||||
/// "both exist" for HouseRecall specifically).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Gated the same way as the existing DAT-backed conformance suite — see
|
||||
/// <see cref="ConformanceDats.ResolveDatDir"/> (also mirrored by
|
||||
/// <c>DoorBugTrajectoryReplayTests.ResolveDatDir</c>): resolve
|
||||
/// <c>ACDREAM_DAT_DIR</c> or the default <c>Documents\Asheron's Call</c>
|
||||
/// path, and skip cleanly (<c>return</c>, no failure) when the dats aren't
|
||||
/// present (CI has no local AC install).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MotionCommandCatalogDatTests
|
||||
{
|
||||
/// <summary>
|
||||
/// True if any local MotionTable can actually animate
|
||||
/// <paramref name="fullCommand"/> — the from-state transition key
|
||||
/// (Links outer, low 16 bits), the animation-bearing inner key
|
||||
/// (Links[...].MotionData), or a Modifiers entry.
|
||||
/// </summary>
|
||||
private static bool ExistsInAnyMotionTable(DatCollection dats, uint fullCommand)
|
||||
{
|
||||
ushort low16 = (ushort)(fullCommand & 0xFFFFu);
|
||||
int fullAsInt = unchecked((int)fullCommand);
|
||||
|
||||
foreach (var id in dats.GetAllIdsOfType<MotionTable>())
|
||||
{
|
||||
var mt = dats.Get<MotionTable>(id);
|
||||
if (mt is null) continue;
|
||||
|
||||
bool outerLow = mt.Links.Keys.Any(k => (k & 0xFFFF) == low16);
|
||||
if (outerLow) return true;
|
||||
|
||||
bool innerFull = mt.Links.Values.Any(md => md.MotionData.ContainsKey(fullAsInt));
|
||||
if (innerFull) return true;
|
||||
|
||||
if (mt.Modifiers.ContainsKey(fullAsInt)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AceShiftedRecallCommands_ExistInLocalMotionTables()
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
Assert.True(ExistsInAnyMotionTable(dats, 0x10000153u), "LifestoneRecall (ACE 0x10000153) must exist in local DAT MotionTables");
|
||||
Assert.True(ExistsInAnyMotionTable(dats, 0x1000013Au), "HouseRecall (ACE 0x1000013A) must exist in local DAT MotionTables");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoThousandThirteenLifestoneAndHouseRecall_AlsoExist()
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// LifestoneRecall's 2013 numbering (0x10000150) does NOT exist locally —
|
||||
// only the ACE-shifted value animates. HouseRecall's 2013 numbering
|
||||
// (0x10000137) DOES exist (the gap doc: "both exist; the old value is
|
||||
// very common").
|
||||
Assert.False(ExistsInAnyMotionTable(dats, 0x10000150u), "LifestoneRecall (2013 0x10000150) unexpectedly found");
|
||||
Assert.True(ExistsInAnyMotionTable(dats, 0x10000137u), "HouseRecall (2013 0x10000137) must also exist in local DAT MotionTables");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000166u)] // MarketplaceRecall (ACE)
|
||||
[InlineData(0x10000171u)] // AllegianceHometownRecall (ACE)
|
||||
[InlineData(0x10000173u)] // OffhandSlashHigh (ACE)
|
||||
public void AceOnlyCommands_ExistOnlyUnderShiftedIds(uint aceFullCommand)
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
Assert.True(ExistsInAnyMotionTable(dats, aceFullCommand),
|
||||
$"ACE value 0x{aceFullCommand:X8} must exist in local DAT MotionTables");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000163u)] // MarketplaceRecall (2013)
|
||||
[InlineData(0x1000016Eu)] // AllegianceHometownRecall (2013)
|
||||
[InlineData(0x10000170u)] // OffhandSlashHigh (2013)
|
||||
public void TwoThousandThirteenOnlyValues_HaveZeroLinkHits(uint retail2013FullCommand)
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
Assert.False(ExistsInAnyMotionTable(dats, retail2013FullCommand),
|
||||
$"2013 value 0x{retail2013FullCommand:X8} unexpectedly found in local DAT MotionTables");
|
||||
}
|
||||
}
|
||||
169
tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs
Normal file
169
tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the dual command-catalog seam introduced for the L.1b
|
||||
/// movement/animation wire-parity slice (<c>IMotionCommandCatalog</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Two catalogs exist because the local DATs and ACE's wire protocol use a
|
||||
/// LATER-client command numbering than the Sept 2013 EoR decomp:
|
||||
/// <see cref="AceModernCommandCatalog"/> (runtime default — matches ACE +
|
||||
/// local DAT MotionTables) and <see cref="Retail2013CommandCatalog"/>
|
||||
/// (conformance/reference — matches the 2013 decomp's
|
||||
/// <c>command_ids[0x198]</c> table verbatim). See
|
||||
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> for the
|
||||
/// full divergence analysis (130 common names with different values; the
|
||||
/// contiguous low-word "+3" shift that begins at <c>SnowAngelState</c>
|
||||
/// (0x43000115 -> 0x43000118), NOT at <c>AllegianceHometownRecall</c> as an
|
||||
/// earlier code comment incorrectly claimed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MotionCommandCatalogTests
|
||||
{
|
||||
private static readonly AceModernCommandCatalog AceModern = new();
|
||||
private static readonly Retail2013CommandCatalog Retail2013 = new();
|
||||
|
||||
// ── ACE mode (AceModernCommandCatalog) ──────────────────────────────
|
||||
//
|
||||
// These four wires sit inside the old override range (0x016E-0x0197)
|
||||
// that MotionCommandResolver used to force-map via a blind per-range
|
||||
// override. Built cleanly from the DatReaderWriter MotionCommand enum,
|
||||
// they already resolve correctly with NO override:
|
||||
// LifestoneRecall = 0x10000153, MarketplaceRecall = 0x10000166,
|
||||
// AllegianceHometownRecall = 0x10000171, OffhandSlashHigh = 0x10000173
|
||||
// (DatReaderWriter.Enums.MotionCommand, Chorizite.DatReaderWriter 2.1.7).
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0153, 0x10000153u)] // LifestoneRecall
|
||||
[InlineData(0x0166, 0x10000166u)] // MarketplaceRecall
|
||||
[InlineData(0x0171, 0x10000171u)] // AllegianceHometownRecall
|
||||
[InlineData(0x0173, 0x10000173u)] // OffhandSlashHigh
|
||||
public void AceModern_ResolvesShiftedRecallAndActionCommands(ushort wire, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, AceModern.ReconstructFullCommand(wire));
|
||||
}
|
||||
|
||||
// Same matrix as MotionCommandResolverTests.ReconstructsKnownCommands —
|
||||
// proves AceModernCommandCatalog reproduces the existing resolver's
|
||||
// behavior for the well-established low end of the command space.
|
||||
[Theory]
|
||||
[InlineData(0x0003, 0x41000003u)] // Ready
|
||||
[InlineData(0x0005, 0x45000005u)] // WalkForward
|
||||
[InlineData(0x0007, 0x44000007u)] // RunForward
|
||||
[InlineData(0x0006, 0x45000006u)] // WalkBackward
|
||||
[InlineData(0x000D, 0x6500000Du)] // TurnRight
|
||||
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
|
||||
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
|
||||
[InlineData(0x0015, 0x40000015u)] // Falling
|
||||
[InlineData(0x0011, 0x40000011u)] // Dead
|
||||
[InlineData(0x0012, 0x41000012u)] // Crouch
|
||||
[InlineData(0x0013, 0x41000013u)] // Sitting
|
||||
[InlineData(0x0014, 0x41000014u)] // Sleeping
|
||||
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
|
||||
[InlineData(0x0058, 0x10000058u)] // ThrustMed
|
||||
[InlineData(0x005B, 0x1000005Bu)] // SlashHigh
|
||||
[InlineData(0x0061, 0x10000061u)] // Shoot
|
||||
[InlineData(0x004B, 0x1000004Bu)] // Jumpup
|
||||
[InlineData(0x0050, 0x10000050u)] // FallDown
|
||||
[InlineData(0x0087, 0x13000087u)] // Wave
|
||||
[InlineData(0x0080, 0x13000080u)] // Laugh
|
||||
[InlineData(0x007D, 0x1300007Du)] // BowDeep
|
||||
public void AceModern_MatchesExistingResolverMatrix(ushort wire, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, AceModern.ReconstructFullCommand(wire));
|
||||
}
|
||||
|
||||
// ── 2013 mode (Retail2013CommandCatalog) ────────────────────────────
|
||||
//
|
||||
// Direct-index reconstruction from acclient_2013_pseudo_c.txt's
|
||||
// command_ids[0x198] table at 0x007c73e8 (line 1017259). The 2013
|
||||
// decomp's *unshifted* values for the same four logical commands.
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0150, 0x10000150u)] // LifestoneRecall (2013 value)
|
||||
[InlineData(0x0163, 0x10000163u)] // MarketplaceRecall (2013 value)
|
||||
[InlineData(0x016E, 0x1000016Eu)] // AllegianceHometownRecall (2013 value)
|
||||
[InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh (2013 value)
|
||||
public void Retail2013_ResolvesUnshiftedRecallAndActionCommands(ushort wire, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, Retail2013.ReconstructFullCommand(wire));
|
||||
}
|
||||
|
||||
// Anchor checks straight off the extracted table — these pin the
|
||||
// extraction itself, independent of the recall-command narrative.
|
||||
[Theory]
|
||||
[InlineData(0x0000, 0x80000000u)]
|
||||
[InlineData(0x0003, 0x41000003u)]
|
||||
[InlineData(0x0005, 0x45000005u)]
|
||||
[InlineData(0x0007, 0x44000007u)]
|
||||
[InlineData(0x000D, 0x6500000Du)]
|
||||
[InlineData(0x0150, 0x10000150u)]
|
||||
[InlineData(0x0153, 0x09000153u)] // NOT LifestoneRecall in 2013 numbering — anchor only.
|
||||
public void Retail2013_AnchorValuesMatchExtractedTable(ushort wire, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, Retail2013.ReconstructFullCommand(wire));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retail2013_OutOfRangeWireReturnsZero()
|
||||
{
|
||||
Assert.Equal(0u, Retail2013.ReconstructFullCommand(0xFFFF));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retail2013_LastInRangeIndexResolves()
|
||||
{
|
||||
// [0x197] is the final entry of command_ids[0x198].
|
||||
Assert.Equal(0x10000197u, Retail2013.ReconstructFullCommand(0x0197));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retail2013_FirstOutOfRangeIndexReturnsZero()
|
||||
{
|
||||
// 0x198 is one past the table's last valid index.
|
||||
Assert.Equal(0u, Retail2013.ReconstructFullCommand(0x0198));
|
||||
}
|
||||
|
||||
// ── Class-priority collision resolution ─────────────────────────────
|
||||
//
|
||||
// Retail's class-byte priority is: lower class byte wins
|
||||
// (Action 0x10 < ChatEmote 0x12/0x13 < Modifier 0x20 < SubState 0x41
|
||||
// < ... < Style 0x80). This is exercised directly against the build
|
||||
// logic via a small synthetic set of colliding low-words, because the
|
||||
// CURRENT DatReaderWriter.Enums.MotionCommand enum (2.1.7, 409 distinct
|
||||
// values) happens to contain zero same-low16 collisions today — so a
|
||||
// matrix test against the live enum alone wouldn't actually exercise
|
||||
// the tie-break rule.
|
||||
|
||||
[Fact]
|
||||
public void ClassPriority_LowerClassByteWins()
|
||||
{
|
||||
var candidates = new Dictionary<ushort, uint>
|
||||
{
|
||||
// Same low word (0x0042) claimed by three different classes;
|
||||
// Action (0x10) must win over Modifier (0x20) and Style (0x80).
|
||||
[0x0042] = 0u,
|
||||
};
|
||||
|
||||
uint[] colliding = { 0x80000042u, 0x20000042u, 0x10000042u };
|
||||
uint resolved = AceModernCommandCatalog.ResolveClassPriority(colliding);
|
||||
|
||||
Assert.Equal(0x10000042u, resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassPriority_OrderOfInputDoesNotMatter()
|
||||
{
|
||||
uint[] collidingReversed = { 0x10000099u, 0x41000099u, 0x80000099u };
|
||||
uint[] collidingForward = { 0x80000099u, 0x41000099u, 0x10000099u };
|
||||
|
||||
Assert.Equal(
|
||||
AceModernCommandCatalog.ResolveClassPriority(collidingForward),
|
||||
AceModernCommandCatalog.ResolveClassPriority(collidingReversed));
|
||||
Assert.Equal(0x10000099u, AceModernCommandCatalog.ResolveClassPriority(collidingForward));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue