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>
130 lines
5.3 KiB
C#
130 lines
5.3 KiB
C#
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");
|
|
}
|
|
}
|