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>
101 lines
3.9 KiB
C#
101 lines
3.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
|
|
|
namespace AcDream.Core.Physics;
|
|
|
|
/// <summary>
|
|
/// Runtime-default <see cref="IMotionCommandCatalog"/>. Built from the
|
|
/// <see cref="DRWMotionCommand"/> enum (generated from the protocol XML;
|
|
/// mirrors ACE's <c>MotionCommand</c> enum and matches the values actually
|
|
/// found in the local DAT <c>MotionTable</c> records). Use this catalog
|
|
/// while talking to ACE — it is what
|
|
/// <see cref="MotionCommandResolver.ReconstructFullCommand"/> delegates to.
|
|
///
|
|
/// <para>
|
|
/// Reconstruction is a flat <c>wireLow16 -> full32</c> 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 <see cref="ResolveClassPriority"/>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>No magic per-range override.</b> The original
|
|
/// <c>MotionCommandResolver.ApplyNamedRetailOverrides</c> force-mapped
|
|
/// wire 0x016E-0x0197 to <c>0x10000000 | lo</c> on the theory that the
|
|
/// generated DRW enum was "shifted." Verified false: building the lookup
|
|
/// straight from <see cref="DRWMotionCommand"/> (Chorizite.DatReaderWriter
|
|
/// 2.1.7, 409 distinct values, zero same-low16 collisions) already resolves
|
|
/// <c>LifestoneRecall</c> (0x0153), <c>MarketplaceRecall</c> (0x0166),
|
|
/// <c>AllegianceHometownRecall</c> (0x0171), and <c>OffhandSlashHigh</c>
|
|
/// (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
|
|
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class AceModernCommandCatalog : IMotionCommandCatalog
|
|
{
|
|
private readonly Dictionary<ushort, uint> _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<ushort, uint> BuildLookup()
|
|
{
|
|
var byLow = new Dictionary<ushort, List<uint>>(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<uint>(1);
|
|
if (!list.Contains(full))
|
|
list.Add(full);
|
|
}
|
|
|
|
var result = new Dictionary<ushort, uint>(byLow.Count);
|
|
foreach (var (lo, candidates) in byLow)
|
|
{
|
|
result[lo] = candidates.Count == 1
|
|
? candidates[0]
|
|
: ResolveClassPriority(candidates);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="BuildLookup"/>.
|
|
/// </summary>
|
|
public static uint ResolveClassPriority(IReadOnlyList<uint> 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;
|
|
}
|
|
}
|