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)); } } }