249 lines
10 KiB
C#
249 lines
10 KiB
C#
using System.Collections.Generic;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Content;
|
|
using AcDream.Core.Input;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
using DatReaderWriter.Lib.IO;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Input;
|
|
|
|
/// <summary>
|
|
/// Campaign OP slice OP8: <see cref="RetailActionMapReader"/> conformance.
|
|
/// Hermetic tests pin the merge/filter mechanism against a synthetic
|
|
/// <see cref="FakeDatObjectSource"/> (no DAT dependency — always runs);
|
|
/// <see cref="RetailActionMapReader_LiveDatTests"/> pins the exact row
|
|
/// counts and spot-labels against the installed dats (skips cleanly when
|
|
/// unavailable, matching <c>ConformanceDats.ResolveDatDir</c>'s pattern).
|
|
/// </summary>
|
|
public sealed class RetailActionMapReaderTests
|
|
{
|
|
[Fact]
|
|
public void Read_FiltersNonUserBindableActions()
|
|
{
|
|
var actionMap = new ActionMap
|
|
{
|
|
StringTableId = 0x23000005u,
|
|
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
|
|
{
|
|
[4u] = new Dictionary<uint, ActionMapValue>
|
|
{
|
|
// Class 0 == not user-bindable — the reader must drop this row.
|
|
[0x1u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 0u } },
|
|
// Class 1 (Movement) — kept.
|
|
[0x29u] = new ActionMapValue
|
|
{
|
|
UserBinding = new UserBindingData
|
|
{
|
|
ActionClass = 1u,
|
|
ActionName = 0x111u,
|
|
ActionDescription = 0x222u,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
|
|
};
|
|
|
|
var dats = new FakeDatObjectSource();
|
|
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
|
|
|
|
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
|
|
|
|
Assert.NotNull(snapshot);
|
|
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
|
|
Assert.Equal(4u, row.InputMapId);
|
|
Assert.Equal(0x29u, row.ActionId);
|
|
Assert.Equal(RetailActionClass.Movement, row.ActionClass);
|
|
Assert.Equal(0x111u, row.LabelHash);
|
|
Assert.Equal(0x222u, row.TooltipHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_UnionMergesBothMasterMapsWithoutOrderDependency()
|
|
{
|
|
// The one real collision the live probe found: context 5 exists in BOTH
|
|
// master maps with completely disjoint action-id sets (2026-08-11 probe,
|
|
// see RetailActionMap.cs class doc unknown #4). Reproduce that shape here
|
|
// and assert the merge picks up defaults from BOTH sources for the SAME
|
|
// context, order-independent.
|
|
var actionMap = new ActionMap
|
|
{
|
|
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
|
|
{
|
|
[5u] = new Dictionary<uint, ActionMapValue>
|
|
{
|
|
[0x3Eu] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
|
|
[0x33u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
|
|
},
|
|
},
|
|
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
|
|
};
|
|
|
|
var gameplayMap = new MasterInputMap
|
|
{
|
|
InputMaps = new Dictionary<uint, CInputMap>
|
|
{
|
|
[5u] = new CInputMap
|
|
{
|
|
Mappings = new List<QualifiedControl>
|
|
{
|
|
new QualifiedControl
|
|
{
|
|
Key = new ControlSpecification { Key = (0xB5u << 16) | 0u, Modifier = 0u },
|
|
Activation = 3u,
|
|
Unknown = 0x3Eu,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
var systemMap = new MasterInputMap
|
|
{
|
|
InputMaps = new Dictionary<uint, CInputMap>
|
|
{
|
|
[5u] = new CInputMap
|
|
{
|
|
Mappings = new List<QualifiedControl>
|
|
{
|
|
new QualifiedControl
|
|
{
|
|
Key = new ControlSpecification { Key = (0x4Au << 16) | 0u, Modifier = 0u },
|
|
Activation = 3u,
|
|
Unknown = 0x33u,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
var dats = new FakeDatObjectSource();
|
|
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
|
|
dats.Add(RetailActionMapIds.GameplayMasterMapId, gameplayMap);
|
|
dats.Add(RetailActionMapIds.SystemMasterMapId, systemMap);
|
|
|
|
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
|
|
|
|
Assert.NotNull(snapshot);
|
|
Assert.Equal(2, snapshot!.Rows.Count);
|
|
|
|
RetailActionMapRow rowFromGameplayMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x3Eu);
|
|
RetailKeyChord chord1 = Assert.Single(rowFromGameplayMap.DefaultBindings);
|
|
Assert.Equal(0xB5u, chord1.Scan);
|
|
|
|
RetailActionMapRow rowFromSystemMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x33u);
|
|
RetailKeyChord chord2 = Assert.Single(rowFromSystemMap.DefaultBindings);
|
|
Assert.Equal(0x4Au, chord2.Scan);
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_MissingActionMap_ReturnsNull()
|
|
{
|
|
var dats = new FakeDatObjectSource();
|
|
Assert.Null(RetailActionMapReader.Read(dats));
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_MissingMasterMaps_StillReturnsRowsWithEmptyDefaults()
|
|
{
|
|
var actionMap = new ActionMap
|
|
{
|
|
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
|
|
{
|
|
[4u] = new Dictionary<uint, ActionMapValue>
|
|
{
|
|
[0x29u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 1u } },
|
|
},
|
|
},
|
|
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
|
|
};
|
|
var dats = new FakeDatObjectSource();
|
|
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
|
|
|
|
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
|
|
|
|
Assert.NotNull(snapshot);
|
|
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
|
|
Assert.Empty(row.DefaultBindings);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
|
|
{
|
|
// docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md §5.3.
|
|
Assert.Equal(19, RetailInputMapHeaders.NameByInputMapId.Count);
|
|
Assert.Equal("ID_InputMap_MovementCommands", RetailInputMapHeaders.NameByInputMapId[0x00000004u]);
|
|
Assert.Equal("ID_InputMap_CameraControls", RetailInputMapHeaders.NameByInputMapId[0x00000005u]);
|
|
Assert.Equal("ID_InputMap_Emotes", RetailInputMapHeaders.NameByInputMapId[0x10000006u]);
|
|
Assert.Equal("ID_InputMap_CharacterOptionCommands", RetailInputMapHeaders.NameByInputMapId[0x10000008u]);
|
|
Assert.Equal("ID_InputMap_ToggleChatEntry", RetailInputMapHeaders.NameByInputMapId[0x1000000Du]);
|
|
}
|
|
|
|
/// <summary>Minimal in-memory <see cref="IDatObjectSource"/> for hermetic tests —
|
|
/// no DAT file dependency.</summary>
|
|
private sealed class FakeDatObjectSource : IDatObjectSource
|
|
{
|
|
private readonly Dictionary<uint, object> _objects = new();
|
|
|
|
public void Add<T>(uint fileId, T value) where T : IDBObj => _objects[fileId] = value!;
|
|
|
|
public T? Get<T>(uint fileId) where T : IDBObj =>
|
|
_objects.TryGetValue(fileId, out object? value) && value is T typed ? typed : default;
|
|
|
|
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
|
|
{
|
|
T? found = Get<T>(fileId);
|
|
value = found!;
|
|
return found is not null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Live-DAT conformance: pins the exact shape of the installed
|
|
/// <c>client_portal.dat</c>/<c>client_local_English.dat</c> ActionMap +
|
|
/// MasterInputMap objects. Skips cleanly when the dats are unavailable.</summary>
|
|
[Trait("Lane", "InstalledDat")]
|
|
public sealed class RetailActionMapReader_LiveDatTests
|
|
{
|
|
[Fact]
|
|
public void Read_AgainstInstalledDats_MatchesPinnedShape()
|
|
{
|
|
string? datDir = Conformance.ConformanceDats.ResolveDatDir();
|
|
if (datDir is null) return; // CI / no local install — skip cleanly.
|
|
|
|
using var dats = new DatCollection(datDir);
|
|
var source = new DatCollectionAdapter(dats);
|
|
|
|
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
|
|
|
|
Assert.NotNull(snapshot);
|
|
// Pinned 2026-08-11 against the installed EoR dats — 306 user-bindable rows
|
|
// across the six non-zero ActionClass buckets (1,2,3,4,5,7 — never 0 or 6).
|
|
Assert.Equal(306, snapshot!.Rows.Count);
|
|
|
|
var byClass = new Dictionary<RetailActionClass, int>();
|
|
foreach (RetailActionMapRow row in snapshot.Rows)
|
|
{
|
|
Assert.NotEqual(RetailActionClass.None, row.ActionClass);
|
|
byClass.TryGetValue(row.ActionClass, out int count);
|
|
byClass[row.ActionClass] = count + 1;
|
|
}
|
|
Assert.Equal(14, byClass[RetailActionClass.Movement]);
|
|
Assert.Equal(22, byClass[RetailActionClass.Camera]);
|
|
Assert.Equal(103, byClass[RetailActionClass.Ui]);
|
|
Assert.Equal(32, byClass[RetailActionClass.Combat]);
|
|
Assert.Equal(87, byClass[RetailActionClass.Emote]);
|
|
Assert.Equal(48, byClass[RetailActionClass.CharacterSettings]);
|
|
|
|
// Spot-pin Movement's "Move Forward" (action 0x29, InputMap 0x4): two
|
|
// default bindings (W + Up arrow), matching KeyBindings.RetailDefaults()'s
|
|
// MovementForward chords.
|
|
RetailActionMapRow moveForward = Assert.Single(
|
|
snapshot.Rows, r => r.InputMapId == 0x4u && r.ActionId == 0x29u);
|
|
Assert.Equal(2, moveForward.DefaultBindings.Count);
|
|
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W
|
|
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW
|
|
}
|
|
}
|