using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Content;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Input;
///
/// Campaign OP slice OP8: the DAT-resident retail keyboard-configuration data
/// (gmKeyboardUI's source of truth — docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md
/// §5.6/§5.8). Retail reads two independent DAT object families:
///
/// - The ActionMap singleton (ActionMap::GetDBOType @0x00685C00
/// = 0x27 — Turbine's internal type tag, NOT the DID's high byte; empirically the object
/// lives at DID ) — one row per
/// (InputMap id, Action id) pair, carrying the action-class bucket
/// (), the label/tooltip string hashes, and whether the
/// action is user-bindable at all (research doc §5.2's ActionMap::IsUserBindable /
/// GetActionClass / GetDescripValues).
/// - Two MasterInputMap DAT objects (
/// / , already extracted by
/// tools/dump-keymap) — the DEFAULT key bindings ICIDM::AddKeyMap merges into
/// the live keymap on RestoreDefaultValues (research doc §5.6).
///
///
///
/// Empirical resolution of research doc §7.4 unknowns 3/4/5 (live-DAT probe against
/// the installed client_portal.dat/client_local_English.dat, 2026-08-11 —
/// no code changed by that probe, only this production port):
///
///
/// - Unknown 3 — the ActionClass enum values. Exactly six
/// non-zero values appear across the ActionMap's 306 user-bindable rows: 1, 2, 3, 4, 5, 7
/// (never 6 — genuinely absent from the shipped DAT, not a probe artifact). Cross-matching
/// each bucket's resolved English labels against the six list-box categories
/// (research doc §5.1) is unambiguous: 1 = Movement ("Move Forward"/"Jump"/...,
/// exactly the 14 rows under InputMap 0x4), 2 = Camera ("Zoom Camera In"/"Rotate
/// Camera Left"/..., InputMaps 0x5/0x6), 3 = UI (item selection + UI toggle-panel +
/// chat + quickslot commands, InputMaps 0x10000007/9/A/C/D), 4 = Combat ("Toggle Combat
/// Mode"/attack-power/aim, InputMaps 0x10000002-5), 5 = Emote ("AFK State"/"Akimbo"/...,
/// InputMap 0x10000006), 7 = CharacterSettings (the "Automatically Repeat Attacks"/
/// "Ignore Allegiance Requests"/... preference-toggle hotkeys, InputMap
/// 0x10000008 — the SAME preference domain OP1's CharacterOptionTable and OP4's
/// Character-tab checkboxes already model, just reachable by a bindable hotkey too).
/// - Unknown 4 — which DBObj::GetByEnum enum is which
/// MasterInputMap DID. Still not traced (would need decompiling
/// DBObj::GetByEnum's dispatch table — out of scope; the two DIDs are already
/// distinguished by content, not by enum). Proven inconsequential: the two master
/// maps' InputMap dictionaries share exactly one context id (0x5) and their action-id
/// sets under that shared context are completely disjoint (gmDefaultMap's ctx 5 carries
/// only action 0x3E; DefaultMap's ctx 5 carries actions 0x33-0x3D — zero overlap). A
/// union-by-(context,action) merge is therefore ORDER-INDEPENDENT and reproduces retail's
/// CMasterInputMap::Merge result regardless of which physical DID retail's
/// AddKeyMap(0x10000001) vs AddKeyMap(1) call resolves to.
/// merges both unconditionally.
/// - Unknown 5 — do the six unnamed InputMaps
/// (TargetedUsage/SystemKeys/MouseCommands/ScrollableControls/EditControls/
/// CopyAndPasteControls) filter by IsUserBindable or render headerless?
/// Resolved: filtered. Every action under InputMap ids 0x1, 0x2, 0x3, 0x7, 0x8, 0xA, 0x10
/// (the unnamed ones, by elimination against research doc §5.3's 19 named ids) carries
/// ActionClass == 0 (non-bindable) with zero exceptions — so
/// AddItemFromTemplateList(0) (the header row) never fires for them at all; they
/// render NOTHING, not an unlabeled row group. The same is true of three NAMED contexts
/// that happen to be 100% non-bindable in the shipped DAT: DialogBoxes (0x9),
/// DebugConsole/ProfilerUI/UIDebugger/DebugCommands (0xB-0xE) — a header string exists for
/// them but is never reached because their action buckets are empty in every
/// class.
///
///
public enum RetailActionClass : uint
{
/// Not user-bindable — ActionMap::IsUserBindable returns false.
/// Never appears as a row's in
/// 's output; filtered at read time.
None = 0,
Movement = 1,
Camera = 2,
Ui = 3,
Combat = 4,
Emote = 5,
// 6 is genuinely absent from the shipped 2013 DAT — not a gap in this table.
CharacterSettings = 7,
}
/// DAT identifiers for the ActionMap singleton and the two master
/// default-keymap objects. Byte-verified against the installed
/// client_portal.dat (2026-08-11 probe, see class doc on
/// ).
public static class RetailActionMapIds
{
/// The one ActionMap DAT object — ICIDM::s_cidm->GetActionMap()'s
/// backing store. Confirmed the ONLY DID of DBObjType ActionMap in the installed dats.
public const uint ActionMapId = 0x26000000u;
/// "gmDefaultMap" — 14 InputMaps (Movement, Camera, and every
/// 0x10000002-0x1000000D gameplay context). tools/dump-keymap's first DID.
public const uint GameplayMasterMapId = 0x14000000u;
/// "DefaultMap" — 7 InputMaps (the system/UI/dialog contexts).
/// tools/dump-keymap's second DID.
public const uint SystemMasterMapId = 0x14000002u;
}
///
/// One raw default-key binding as stored in a MasterInputMap's
/// QualifiedControl — retail's own packed (scan << 16 | device) key plus
/// the modifier bitmask and activation byte. Deliberately NOT
/// AcDream.UI.Abstractions.Input.KeyChord: this is the DAT's own encoding
/// (DIK scan codes), translated to a live KeyChord by the App-layer controller
/// (Core has no Silk.NET dependency).
///
public readonly record struct RetailKeyChord(uint Scan, uint Device, uint Modifier, uint Activation);
/// One ActionMap row: a single (InputMap id, Action id) pair that retail's
/// Configure Keyboard screen renders as one UIOption_ActionKeyMap row.
public sealed record RetailActionMapRow(
uint InputMapId,
uint ActionId,
RetailActionClass ActionClass,
uint LabelHash,
uint TooltipHash,
IReadOnlyList DefaultBindings);
/// The complete read result: every user-bindable ActionMap row, plus the raw
/// row count read (for conformance pinning against the installed dats).
public sealed record RetailActionMapSnapshot(IReadOnlyList Rows);
///
/// Retail's 19 named InputMapID -> ID_InputMap_* string-table keys
/// (gmKeyboardUI::GetStringInfoFromInputMapID @0x004DA980, research doc §5.3,
/// byte-verified at .rdata 0x007BE45C..0x007BE648). String table
/// 0x23000005 is the SAME table 's label/tooltip
/// hashes resolve against — ActionMap.StringTableId confirms this empirically.
///
public static class RetailInputMapHeaders
{
public const uint StringTableId = 0x23000005u;
public static readonly IReadOnlyDictionary NameByInputMapId =
new Dictionary
{
[0x00000004u] = "ID_InputMap_MovementCommands",
[0x00000005u] = "ID_InputMap_CameraControls",
[0x00000006u] = "ID_InputMap_CameraAlternateControls",
[0x00000009u] = "ID_InputMap_DialogBoxes",
[0x0000000Bu] = "ID_InputMap_DebugConsole",
[0x0000000Cu] = "ID_InputMap_ProfilerUI",
[0x0000000Du] = "ID_InputMap_UIDebugger",
[0x0000000Eu] = "ID_InputMap_DebugCommands",
[0x10000002u] = "ID_InputMap_Combat",
[0x10000003u] = "ID_InputMap_MeleeCombat",
[0x10000004u] = "ID_InputMap_MissileCombat",
[0x10000005u] = "ID_InputMap_MagicCombat",
[0x10000006u] = "ID_InputMap_Emotes",
[0x10000007u] = "ID_InputMap_ItemSelectionCommands",
[0x10000008u] = "ID_InputMap_CharacterOptionCommands",
[0x10000009u] = "ID_InputMap_UICommands",
[0x1000000Au] = "ID_InputMap_ChatCommands",
[0x1000000Cu] = "ID_InputMap_QuickslotCommands",
[0x1000000Du] = "ID_InputMap_ToggleChatEntry",
};
}
///
/// Reads the retail ActionMap + both MasterInputMap DAT objects into
/// . Pure data extraction — no string
/// resolution (Core has no Silk.NET/font dependency for that; the App-layer
/// controller resolves /
/// against the same way every other
/// Campaign OP page controller resolves its own strings).
///
public static class RetailActionMapReader
{
///
/// Reads and merges the ActionMap singleton with both MasterInputMap defaults.
/// Returns null if the ActionMap object is missing from the dats (a corrupt/non-EoR
/// install — the caller degrades to "Configure Keyboard has no rows" like every other
/// controller's "required root not found" path).
///
public static RetailActionMapSnapshot? Read(IDatObjectSource dats)
{
ArgumentNullException.ThrowIfNull(dats);
ActionMap? actionMap = dats.Get(RetailActionMapIds.ActionMapId);
if (actionMap is null)
return null;
// Both master maps are optional individually — a missing one just means
// "no DAT defaults for that half of the merged keymap" rather than a hard
// failure; ActionMap alone still lets the screen render every row's
// label/tooltip/class with an empty default-binding list.
MasterInputMap? gameplayMap = dats.Get(RetailActionMapIds.GameplayMasterMapId);
MasterInputMap? systemMap = dats.Get(RetailActionMapIds.SystemMasterMapId);
var rows = new List();
foreach (var inputMapEntry in actionMap.InputMaps)
{
uint inputMapId = inputMapEntry.Key;
foreach (var actionEntry in inputMapEntry.Value)
{
uint actionId = actionEntry.Key;
var value = actionEntry.Value;
var userBinding = value.UserBinding;
uint classId = userBinding?.ActionClass ?? 0u;
if (classId == 0u)
continue; // ActionMap::IsUserBindable false — retail never rows this.
var defaults = new List();
CollectDefaults(gameplayMap, inputMapId, actionId, defaults);
CollectDefaults(systemMap, inputMapId, actionId, defaults);
rows.Add(new RetailActionMapRow(
inputMapId,
actionId,
(RetailActionClass)classId,
userBinding!.ActionName,
userBinding.ActionDescription,
defaults));
}
}
return new RetailActionMapSnapshot(rows);
}
private static void CollectDefaults(
MasterInputMap? map, uint inputMapId, uint actionId, List into)
{
if (map is null) return;
if (!map.InputMaps.TryGetValue(inputMapId, out var cInputMap)) return;
foreach (var control in cInputMap.Mappings)
{
if (control.Unknown != actionId) continue;
uint scan = (control.Key.Key >> 16) & 0xFFFFu;
uint device = control.Key.Key & 0xFFFFu;
into.Add(new RetailKeyChord(scan, device, control.Key.Modifier, control.Activation));
}
}
}