From 2c8620ea948d93b73d3fd95142d07857c7d86db0 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 30 Jun 2026 22:13:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(L.1b):=20dual=20MotionCommand=20catalog=20?= =?UTF-8?q?=E2=80=94=20AceModern=20runtime=20+=20Retail2013=20conformance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 19 ++ .../Physics/AceModernCommandCatalog.cs | 101 +++++++++++ .../Physics/IMotionCommandCatalog.cs | 27 +++ .../Physics/MotionCommandResolver.cs | 86 +++------ .../Physics/Retail2013CommandCatalog.cs | 113 ++++++++++++ .../Combat/CombatAnimationPlannerTests.cs | 32 +++- .../Physics/MotionCommandCatalogDatTests.cs | 130 ++++++++++++++ .../Physics/MotionCommandCatalogTests.cs | 169 ++++++++++++++++++ 8 files changed, 612 insertions(+), 65 deletions(-) create mode 100644 src/AcDream.Core/Physics/AceModernCommandCatalog.cs create mode 100644 src/AcDream.Core/Physics/IMotionCommandCatalog.cs create mode 100644 src/AcDream.Core/Physics/Retail2013CommandCatalog.cs create mode 100644 tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index de8b7dd0..0847e5ce 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,25 @@ Copy this block when adding a new issue: --- +## #159 — CombatAnimationPlanner uses 2013-decomp command numbering, not ACE/DRW + +**Status:** OPEN +**Severity:** MEDIUM (late-combat animation classification wrong against ACE) +**Filed:** 2026-06-30 +**Component:** animation, combat + +**Description:** `CombatAnimationPlanner.CombatAnimationMotionCommands` hardcodes the late-combat command constants (the `Offhand*` / `Attack4-6` / `Punch*` block) using **2013-decomp numbering** instead of the ACE/DatReaderWriter numbering that ACE actually broadcasts and that the local DAT MotionTables use. Per the +3-ish low-word shift documented in the ACE-vs-2013 gap research, e.g. `OffhandSlashHigh` should be `0x10000173` not `0x10000170`; `AttackLow6` should be `0x1000018E` not `0x1000018B`. Against a live ACE server these specific commands will be silently misclassified (resolver returns the correct ACE value, but the planner's set contains the 2013 value, so no match). + +**Root cause / status:** Pre-existing — surfaced by the L.1b command-catalog slice (commit pending). The old blind `0x016E–0x0197` override in `MotionCommandResolver` masked the matching test (`CombatAnimationPlannerTests.MotionCommandResolver_UsesNamedRetailLateCombatCommands`) by force-mapping the same wire range to 2013-class values, so the test agreed with the planner's wrong numbering. Deleting the override (correct) exposed the mismatch. NOT a regression: for the real ACE wire value (`0x0173`), the resolver returns `0x10000173` both before and after the override deletion, so runtime behavior is unchanged — the planner was already misclassifying it. The fix is to renumber the `CombatAnimationMotionCommands` block to the ACE/DRW values (cross-check each constant against `DatReaderWriter.Enums.MotionCommand`). + +**Files:** `src/AcDream.Core/Combat/CombatAnimationPlanner.cs:268-307` (the hardcoded `CombatAnimationMotionCommands` block). + +**Research:** `docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md`. + +**Acceptance:** Each late-combat constant in `CombatAnimationMotionCommands` matches its `DatReaderWriter.Enums.MotionCommand` value; a parity test asserts the ACE wire values (`0x0173 → OffhandSlashHigh`, etc.) classify correctly through `ClassifyMotionCommand`. + +--- + ## #158 — Character window — deferred polish **Status:** OPEN diff --git a/src/AcDream.Core/Physics/AceModernCommandCatalog.cs b/src/AcDream.Core/Physics/AceModernCommandCatalog.cs new file mode 100644 index 00000000..0294f59e --- /dev/null +++ b/src/AcDream.Core/Physics/AceModernCommandCatalog.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.Core.Physics; + +/// +/// Runtime-default . Built from the +/// enum (generated from the protocol XML; +/// mirrors ACE's MotionCommand enum and matches the values actually +/// found in the local DAT MotionTable records). Use this catalog +/// while talking to ACE — it is what +/// delegates to. +/// +/// +/// Reconstruction is a flat wireLow16 -> full32 lookup built once +/// at construction. When more than one enum value shares the same low 16 +/// bits (a true class collision — class byte differs, low word doesn't), +/// the LOWER class byte wins: Action (0x10) < ChatEmote (0x12/0x13) +/// < Modifier (0x20) < SubState (0x41/...) < ... < Style (0x80). +/// See . +/// +/// +/// +/// No magic per-range override. The original +/// MotionCommandResolver.ApplyNamedRetailOverrides force-mapped +/// wire 0x016E-0x0197 to 0x10000000 | lo on the theory that the +/// generated DRW enum was "shifted." Verified false: building the lookup +/// straight from (Chorizite.DatReaderWriter +/// 2.1.7, 409 distinct values, zero same-low16 collisions) already resolves +/// LifestoneRecall (0x0153), MarketplaceRecall (0x0166), +/// AllegianceHometownRecall (0x0171), and OffhandSlashHigh +/// (0x0173) to their correct Action-class full values with no override. +/// The override loop is deleted in this slice (it was masking nothing; the +/// enum was already correct). See +/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md. +/// +/// +public sealed class AceModernCommandCatalog : IMotionCommandCatalog +{ + private readonly Dictionary _lookup; + + public AceModernCommandCatalog() + { + _lookup = BuildLookup(); + } + + public uint ReconstructFullCommand(ushort wireCommand) + { + if (wireCommand == 0) return 0u; + _lookup.TryGetValue(wireCommand, out var full); + return full; + } + + private static Dictionary BuildLookup() + { + var byLow = new Dictionary>(512); + foreach (DRWMotionCommand v in Enum.GetValues(typeof(DRWMotionCommand))) + { + uint full = (uint)v; + ushort lo = (ushort)(full & 0xFFFFu); + if (lo == 0) continue; // Invalid / unmappable + + if (!byLow.TryGetValue(lo, out var list)) + byLow[lo] = list = new List(1); + if (!list.Contains(full)) + list.Add(full); + } + + var result = new Dictionary(byLow.Count); + foreach (var (lo, candidates) in byLow) + { + result[lo] = candidates.Count == 1 + ? candidates[0] + : ResolveClassPriority(candidates); + } + + return result; + } + + /// + /// Given a set of full 32-bit MotionCommand values that all share the + /// same low 16 bits, return the one whose class byte (bits 24-31) is + /// numerically lowest — retail's class priority (Action 0x10 beats + /// Modifier 0x20 beats SubState 0x41 beats Style 0x80, etc). Exposed + /// as a static so it can be exercised directly by tests even when the + /// live enum has no real collisions to exercise it through + /// . + /// + public static uint ResolveClassPriority(IReadOnlyList candidates) + { + uint best = candidates[0]; + for (int i = 1; i < candidates.Count; i++) + { + uint candidate = candidates[i]; + if ((candidate >> 24) < (best >> 24)) + best = candidate; + } + return best; + } +} diff --git a/src/AcDream.Core/Physics/IMotionCommandCatalog.cs b/src/AcDream.Core/Physics/IMotionCommandCatalog.cs new file mode 100644 index 00000000..9951a6df --- /dev/null +++ b/src/AcDream.Core/Physics/IMotionCommandCatalog.cs @@ -0,0 +1,27 @@ +namespace AcDream.Core.Physics; + +/// +/// Reconstructs a full 32-bit retail MotionCommand from the 16-bit wire +/// value broadcast in InterpretedMotionState.Commands[] (the server +/// truncates the class byte — see for +/// why that byte must be restored before routing). +/// +/// +/// Two implementations exist because the wire-numbering used by ACE / the +/// local DATs and the wire-numbering used by the Sept 2013 EoR retail +/// decomp diverge for ~130 command names (a contiguous low-word "+3" shift +/// starting at SnowAngelState). See +/// (runtime default) and +/// (conformance/reference), and +/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md for the +/// full divergence analysis. +/// +/// +public interface IMotionCommandCatalog +{ + /// + /// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value. + /// Returns 0 if no entry matches. + /// + uint ReconstructFullCommand(ushort wireCommand); +} diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs index 016d8e16..ce16c4b9 100644 --- a/src/AcDream.Core/Physics/MotionCommandResolver.cs +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -1,12 +1,8 @@ -using System; -using System.Collections.Generic; -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - namespace AcDream.Core.Physics; /// -/// Reconstructs the 32-bit retail value from -/// a 16-bit wire value broadcast in InterpretedMotionState.Commands[]. +/// Reconstructs the 32-bit retail MotionCommand value from a 16-bit wire +/// value broadcast in InterpretedMotionState.Commands[]. /// /// /// The server serializes MotionCommands as u16 (ACE @@ -19,11 +15,19 @@ namespace AcDream.Core.Physics; /// /// /// -/// This is implemented as an eager lookup table built from all values of -/// via reflection. If the wire value matches -/// more than one enum value (different class bits), we prefer the -/// lowest-class-numbered variant that has a non-zero class byte — roughly -/// matching retail priority (Action < Modifier < SubState < Style). +/// As of the L.1b command-catalog slice, this static facade delegates to a +/// single shared instance — the +/// runtime-default catalog built from the DatReaderWriter +/// MotionCommand enum (matches ACE + the local DATs). All ~10 +/// existing runtime callers (AnimationCommandRouter, +/// CombatAnimationPlanner, 8x GameWindow) are unaffected by +/// this refactor — the public signature and behavior for the ACE/runtime +/// path is unchanged. A second catalog, +/// , is available for +/// conformance/reference work against the Sept 2013 EoR decomp's own +/// (differently-numbered) command table; callers that need that catalog +/// must construct it directly via the +/// seam — this facade only ever returns ACE/runtime values. /// /// /// @@ -42,66 +46,26 @@ namespace AcDream.Core.Physics; /// docs/research/deepdives/r03-motion-animation.md §3 — complete /// command catalogue. /// +/// +/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md — +/// the ACE-vs-2013 catalog divergence that motivated the dual-catalog +/// seam (the shift begins at SnowAngelState, 0x43000115 -> +/// 0x43000118, not at AllegianceHometownRecall). +/// /// /// /// public static class MotionCommandResolver { - // Lookup table built eagerly at type-init. Sparse: only values that - // appear in the DRW enum (which came from the generated protocol XML) - // are present. ~450 entries typical. - private static readonly Dictionary s_lookup = BuildLookup(); + private static readonly AceModernCommandCatalog s_aceModern = new(); /// /// Given a 16-bit wire value, return the full 32-bit MotionCommand - /// (class byte restored). Returns 0 if no matching enum value exists. + /// (class byte restored) per the ACE/runtime catalog. Returns 0 if no + /// matching value exists. /// public static uint ReconstructFullCommand(ushort wireCommand) { - if (wireCommand == 0) return 0u; - s_lookup.TryGetValue(wireCommand, out var full); - return full; - } - - private static Dictionary BuildLookup() - { - var result = new Dictionary(512); - var values = Enum.GetValues(typeof(DRWMotionCommand)); - foreach (DRWMotionCommand v in values) - { - uint full = (uint)v; - ushort lo = (ushort)(full & 0xFFFFu); - if (lo == 0) continue; // Invalid / unmappable - - // If a value with this low-16-bit already exists, keep the one - // with the lower class byte (Action=0x10 beats SubState=0x41 - // beats Style=0x80). This matches retail: the server tends to - // emit Actions and ChatEmotes far more often than Styles, so - // the Action-class reconstruction is the common case. - if (!result.TryGetValue(lo, out var existing) - || (full >> 24) < (existing >> 24)) - { - result[lo] = full; - } - } - - ApplyNamedRetailOverrides(result); - return result; - } - - private static void ApplyNamedRetailOverrides(Dictionary result) - { - // The generated DRW enum is shifted by three entries starting at - // AllegianceHometownRecall. The named Sept 2013 retail command_ids - // table is authoritative here: - // named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658 - // and command-name table lines 1068272-1068313. - // - // These values cover recall, offhand, attack 4-6, and fast/slow punch - // actions. Without the override, wire command 0x0170 reconstructs to - // IssueSlashCommand instead of OffhandSlashHigh, so offhand swing - // animations route as UI commands and never play. - for (ushort lo = 0x016E; lo <= 0x0197; lo++) - result[lo] = 0x10000000u | lo; + return s_aceModern.ReconstructFullCommand(wireCommand); } } diff --git a/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs b/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs new file mode 100644 index 00000000..78a48c51 --- /dev/null +++ b/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs @@ -0,0 +1,113 @@ +namespace AcDream.Core.Physics; + +/// +/// Conformance/reference built from the +/// Sept 2013 EoR retail decomp's full command_ids[0x198] table — a +/// direct index from 16-bit wire low-word to the 32-bit MotionCommand the +/// 2013 client itself would reconstruct. NOT the runtime default (ACE / the +/// local DATs use a later-client numbering — see +/// ); this catalog exists to prove and +/// pin the 2013 decomp's behavior for conformance tests. +/// +/// +/// Provenance: docs/research/named-retail/acclient_2013_pseudo_c.txt, +/// uint32_t const command_ids[0x198] at address 0x007c73e8, +/// table body starting at source line 1017259 +/// ([0x000] = 0x80000000) through line 1017667 +/// ([0x197] = 0x10000197), closing brace at line 1017668. Extracted +/// programmatically (regex over [0xNNN] = 0xVVVVVVVV, not hand-typed) +/// and verified against the anchor values cited inline below. +/// +/// +/// +/// Caution — two look-alike tables exist elsewhere in the same file +/// (around source lines 1017962 and 1068315, both also declared +/// command_ids[0x198]). Only the table at line 1017259 / +/// address 0x007c73e8 is used here; do not conflate with the others. +/// +/// +public sealed class Retail2013CommandCatalog : IMotionCommandCatalog +{ + public uint ReconstructFullCommand(ushort wireCommand) + { + return wireCommand < CommandIds.Length ? CommandIds[wireCommand] : 0u; + } + + // acclient_2013_pseudo_c.txt:1017259-1017667, address 0x007c73e8. + // 0x198 (408) entries, indices 0x000..0x197, verbatim from the decomp. + // Anchors verified at extraction time: + // [0x000]=0x80000000 [0x003]=0x41000003 [0x005]=0x45000005 + // [0x007]=0x44000007 [0x00d]=0x6500000d [0x150]=0x10000150 + // [0x153]=0x09000153 + private static readonly uint[] CommandIds = + { + 0x80000000u, 0x85000001u, 0x85000002u, 0x41000003u, 0x40000004u, 0x45000005u, + 0x45000006u, 0x44000007u, 0x40000008u, 0x40000009u, 0x4000000Au, 0x4000000Bu, + 0x4000000Cu, 0x6500000Du, 0x6500000Eu, 0x6500000Fu, 0x65000010u, 0x40000011u, + 0x41000012u, 0x41000013u, 0x41000014u, 0x40000015u, 0x40000016u, 0x40000017u, + 0x40000018u, 0x40000019u, 0x4000001Au, 0x4000001Bu, 0x4000001Cu, 0x4000001Du, + 0x4000001Eu, 0x4000001Fu, 0x40000020u, 0x40000021u, 0x40000022u, 0x40000023u, + 0x40000024u, 0x40000025u, 0x40000026u, 0x40000027u, 0x40000028u, 0x40000029u, + 0x4000002Au, 0x4000002Bu, 0x4000002Cu, 0x4000002Du, 0x4000002Eu, 0x4000002Fu, + 0x40000030u, 0x40000031u, 0x40000032u, 0x40000033u, 0x40000034u, 0x40000035u, + 0x40000036u, 0x40000037u, 0x40000038u, 0x40000039u, 0x2000003Au, 0x2500003Bu, + 0x8000003Cu, 0x8000003Du, 0x8000003Eu, 0x8000003Fu, 0x80000040u, 0x80000041u, + 0x80000042u, 0x80000043u, 0x80000044u, 0x80000045u, 0x80000046u, 0x80000047u, + 0x80000048u, 0x80000049u, 0x1000004Au, 0x1000004Bu, 0x1300004Cu, 0x1000004Du, + 0x1000004Eu, 0x1000004Fu, 0x10000050u, 0x10000051u, 0x10000052u, 0x10000053u, + 0x10000054u, 0x10000055u, 0x10000056u, 0x10000057u, 0x10000058u, 0x10000059u, + 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, 0x1000005Eu, 0x1000005Fu, + 0x10000060u, 0x10000061u, 0x10000062u, 0x10000063u, 0x10000064u, 0x10000065u, + 0x10000066u, 0x10000067u, 0x10000068u, 0x10000069u, 0x1000006Au, 0x1000006Bu, + 0x1000006Cu, 0x1000006Du, 0x1000006Eu, 0x1000006Fu, 0x10000070u, 0x10000071u, + 0x10000072u, 0x10000073u, 0x10000074u, 0x10000075u, 0x10000076u, 0x10000077u, + 0x10000078u, 0x13000079u, 0x1300007Au, 0x1300007Bu, 0x1300007Cu, 0x1300007Du, + 0x1300007Eu, 0x1300007Fu, 0x13000080u, 0x13000081u, 0x13000082u, 0x13000083u, + 0x13000084u, 0x13000085u, 0x13000086u, 0x13000087u, 0x13000088u, 0x13000089u, + 0x1300008Au, 0x1300008Bu, 0x1300008Cu, 0x1300008Du, 0x1300008Eu, 0x1300008Fu, + 0x13000090u, 0x13000091u, 0x13000092u, 0x13000093u, 0x13000094u, 0x13000095u, + 0x13000096u, 0x13000097u, 0x13000098u, 0x13000099u, 0x1300009Au, 0x1200009Bu, + 0x1000009Cu, 0x1000009Du, 0x1000009Eu, 0x1000009Fu, 0x100000A0u, 0x100000A1u, + 0x080000A2u, 0x090000A3u, 0x090000A4u, 0x090000A5u, 0x090000A6u, 0x090000A7u, + 0x090000A8u, 0x080000A9u, 0x090000AAu, 0x090000ABu, 0x090000ACu, 0x090000ADu, + 0x090000AEu, 0x090000AFu, 0x090000B0u, 0x090000B1u, 0x0D0000B2u, 0x0D0000B3u, + 0x0D0000B4u, 0x080000B5u, 0x080000B6u, 0x080000B7u, 0x090000B8u, 0x090000B9u, + 0x0D0000BAu, 0x0D0000BBu, 0x0D0000BCu, 0x0D0000BDu, 0x0D0000BEu, 0x0D0000BFu, + 0x090000C0u, 0x0C0000C1u, 0x090000C2u, 0x090000C3u, 0x090000C4u, 0x0D0000C5u, + 0x090000C6u, 0x090000C7u, 0x090000C8u, 0x090000C9u, 0x130000CAu, 0x130000CBu, + 0x130000CCu, 0x100000CDu, 0x100000CEu, 0x100000CFu, 0x100000D0u, 0x100000D1u, + 0x100000D2u, 0x400000D3u, 0x120000D4u, 0x090000D5u, 0x090000D6u, 0x090000D7u, + 0x090000D8u, 0x090000D9u, 0x090000DAu, 0x090000DBu, 0x090000DCu, 0x090000DDu, + 0x090000DEu, 0x120000DFu, 0x400000E0u, 0x400000E1u, 0x100000E2u, 0x100000E3u, + 0x400000E4u, 0x400000E5u, 0x400000E6u, 0x090000E7u, 0x800000E8u, 0x800000E9u, + 0x430000EAu, 0x430000EBu, 0x430000ECu, 0x430000EDu, 0x430000EEu, 0x430000EFu, + 0x430000F0u, 0x430000F1u, 0x430000F2u, 0x430000F3u, 0x430000F4u, 0x430000F5u, + 0x430000F6u, 0x430000F7u, 0x430000F8u, 0x420000F9u, 0x430000FAu, 0x430000FBu, + 0x430000FCu, 0x430000FDu, 0x090000FEu, 0x090000FFu, 0x09000100u, 0x09000101u, + 0x09000102u, 0x09000103u, 0x09000104u, 0x09000105u, 0x09000106u, 0x09000107u, + 0x09000108u, 0x09000109u, 0x0900010Au, 0x0900010Bu, 0x0900010Cu, 0x0900010Du, + 0x1000010Eu, 0x0900010Fu, 0x09000110u, 0x09000111u, 0x09000112u, 0x09000113u, + 0x09000114u, 0x43000115u, 0x13000116u, 0x43000117u, 0x43000118u, 0x43000119u, + 0x0900011Au, 0x1000011Bu, 0x1000011Cu, 0x1000011Du, 0x1000011Eu, 0x1000011Fu, + 0x10000120u, 0x10000121u, 0x10000122u, 0x10000123u, 0x10000124u, 0x10000125u, + 0x10000126u, 0x10000127u, 0x10000128u, 0x10000129u, 0x1000012Au, 0x1000012Bu, + 0x1000012Cu, 0x1000012Du, 0x1000012Eu, 0x1000012Fu, 0x10000130u, 0x10000131u, + 0x13000132u, 0x40000133u, 0x40000134u, 0x40000135u, 0x40000136u, 0x10000137u, + 0x80000138u, 0x80000139u, 0x4300013Au, 0x4300013Bu, 0x4300013Cu, 0x4300013Du, + 0x4300013Eu, 0x4300013Fu, 0x43000140u, 0x43000141u, 0x43000142u, 0x43000143u, + 0x43000144u, 0x43000145u, 0x43000146u, 0x13000147u, 0x13000148u, 0x13000149u, + 0x1300014Au, 0x1300014Bu, 0x1300014Cu, 0x1300014Du, 0x1300014Eu, 0x1300014Fu, + 0x10000150u, 0x09000151u, 0x09000152u, 0x09000153u, 0x09000154u, 0x09000155u, + 0x09000156u, 0x09000157u, 0x09000158u, 0x09000159u, 0x0900015Au, 0x0900015Bu, + 0x0900015Cu, 0x0900015Du, 0x0900015Eu, 0x0900015Fu, 0x09000160u, 0x09000161u, + 0x10000162u, 0x10000163u, 0x10000164u, 0x09000165u, 0x09000166u, 0x09000167u, + 0x09000168u, 0x09000169u, 0x0900016Au, 0x0900016Bu, 0x0900016Cu, 0x0900016Du, + 0x1000016Eu, 0x1000016Fu, 0x10000170u, 0x10000171u, 0x10000172u, 0x10000173u, + 0x10000174u, 0x10000175u, 0x10000176u, 0x10000177u, 0x10000178u, 0x10000179u, + 0x1000017Au, 0x1000017Bu, 0x1000017Cu, 0x1000017Du, 0x1000017Eu, 0x1000017Fu, + 0x10000180u, 0x10000181u, 0x10000182u, 0x10000183u, 0x10000184u, 0x10000185u, + 0x10000186u, 0x10000187u, 0x10000188u, 0x10000189u, 0x1000018Au, 0x1000018Bu, + 0x1000018Cu, 0x1000018Du, 0x1000018Eu, 0x1000018Fu, 0x10000190u, 0x10000191u, + 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u, 0x10000196u, 0x10000197u, + }; +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs index 7a4a9a1f..d682646c 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -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) diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs new file mode 100644 index 00000000..da169456 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs @@ -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; + +/// +/// Real-DAT availability tests for the L.1b dual command catalog. A command +/// only actually ANIMATES if the entity's MotionTable has a +/// Links or Modifiers entry for the full 32-bit value — an +/// enum/table value alone proves nothing about whether the local DATs will +/// ever play it. +/// +/// +/// Reproduces the scan from +/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md as +/// pinned assertions: a command "exists" for a MotionTable when its full +/// 32-bit value appears as either (a) an outer Links key's low 16 +/// bits (the from-state -> command transition key), (b) an inner +/// Links[...].MotionData key (the actual animation-bearing target), +/// or (c) a Modifiers key. This three-way check is what reproduces +/// the gap doc's hit counts exactly (verified against a live scan of the +/// local client_portal.dat: 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). +/// +/// +/// +/// Gated the same way as the existing DAT-backed conformance suite — see +/// (also mirrored by +/// DoorBugTrajectoryReplayTests.ResolveDatDir): resolve +/// ACDREAM_DAT_DIR or the default Documents\Asheron's Call +/// path, and skip cleanly (return, no failure) when the dats aren't +/// present (CI has no local AC install). +/// +/// +public class MotionCommandCatalogDatTests +{ + /// + /// True if any local MotionTable can actually animate + /// — the from-state transition key + /// (Links outer, low 16 bits), the animation-bearing inner key + /// (Links[...].MotionData), or a Modifiers entry. + /// + private static bool ExistsInAnyMotionTable(DatCollection dats, uint fullCommand) + { + ushort low16 = (ushort)(fullCommand & 0xFFFFu); + int fullAsInt = unchecked((int)fullCommand); + + foreach (var id in dats.GetAllIdsOfType()) + { + var mt = dats.Get(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"); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs new file mode 100644 index 00000000..7610cd8b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Validates the dual command-catalog seam introduced for the L.1b +/// movement/animation wire-parity slice (IMotionCommandCatalog). +/// +/// +/// Two catalogs exist because the local DATs and ACE's wire protocol use a +/// LATER-client command numbering than the Sept 2013 EoR decomp: +/// (runtime default — matches ACE + +/// local DAT MotionTables) and +/// (conformance/reference — matches the 2013 decomp's +/// command_ids[0x198] table verbatim). See +/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md for the +/// full divergence analysis (130 common names with different values; the +/// contiguous low-word "+3" shift that begins at SnowAngelState +/// (0x43000115 -> 0x43000118), NOT at AllegianceHometownRecall as an +/// earlier code comment incorrectly claimed). +/// +/// +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 + { + // 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)); + } +}