using System.Collections.Generic; using System.Linq; using AcDream.Content; using AcDream.Core.Input; using AcDream.UI.Abstractions.Input; using DatReaderWriter; using Xunit; namespace AcDream.Core.Tests.Input; /// /// Campaign OP slice OP8: pins 's agreement /// with — for every /// this slice's table resolves, the UNION of DAT default bindings across every DAT /// row mapped to that action must equal KeyBindings.RetailDefaults()'s chord /// set for it. Per the slice contract: "investigate + report any disagreement rather /// than silently preferring one." Skips cleanly when the installed dats are /// unavailable (CI), matching every other live-DAT conformance test in this project. /// /// /// Three real, byte-verified disagreements survive after the mechanism fixes /// (2026-08-11 investigation — none are bugs in this slice's table; all three are /// PRE-EXISTING gaps/design choices this /// slice does not touch, listed in /// with citations): /// /// /// MovementWalkMode. The DAT's raw QualifiedControl.Modifier /// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate /// "modifier" to report when the primary key and the modifier are the same physical /// key). RetailDefaults() deliberately encodes Modifiers=Shift anyway — /// its own comment (K-fix1, 2026-04-26) explains the OS echoes /// CurrentModifiers=Shift alongside a Shift key-DOWN event, so the chord must /// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT /// artifact this slice's reader faithfully reproduces. /// Ten CameraAlternateControls (InputMap 0x6) actions. Retail /// ships TWO camera-control schemes with DIFFERENT default keys: InputMap 0x5's /// (Numpad: Keypad4/6/8/2 for rotate, KeypadSubtract/Add for zoom, ...) and InputMap /// 0x6's (Arrow keys: Left/Right/Up/Down for rotate, ...). Both InputMaps' actions /// share the SAME bucket and this slice /// correctly maps BOTH to the same (research doc §5.3: a /// user can rebind either scheme's row independently). RetailDefaults() — a /// PRE-EXISTING, OP8-independent file — only carries the Numpad (0x5) scheme; it does /// not carry the arrow-key (0x6) alternates as SECOND bindings for the same action. /// This is a genuine RetailDefaults() completeness gap, reported here rather /// than silently patched into a foundational, heavily-tested file outside this /// slice's scope (register row filed). /// Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9 /// counterpart). The DAT's own default /// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" — /// UseQuickSlot_N), NOT to the separate "Select Quickslot N" action id /// (SelectQuickSlot_N, DAT action ids 0x1000004E-56) — those carry NO /// default binding at all in the shipped DAT. RetailDefaults()'s own comment /// (citing gmToolbarUI::ListenToGlobalMessage @0x004BE4E0) asserts retail's /// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw /// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch /// function's own modifier branching). Both readings are independently retail- /// sourced; reconciling them needs the decompiled dispatch function, out of scope /// here. Reported, not silently resolved either way. /// /// public sealed class RetailActionIdentityRoundTripTests { /// Actions with a citation-backed, pre-existing reason their DAT-union /// default set legitimately differs from /// — see class doc. Every other mapped action must match exactly. private static readonly HashSet KnownRetailDefaultsDisagreements = new() { InputAction.MovementWalkMode, InputAction.CameraMoveToward, InputAction.CameraMoveAway, InputAction.CameraRotateLeft, InputAction.CameraRotateRight, InputAction.CameraRotateUp, InputAction.CameraRotateDown, InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson, InputAction.CameraViewLookDown, InputAction.CameraViewMapMode, InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3, InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6, InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9, // Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's // own "Select Quickslot N" action ids carry NO default binding at all — // RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled // dispatch function's runtime modifier check, not the raw keymap default. InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3, InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6, InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9, }; [Fact] public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults() { string? datDir = Conformance.ConformanceDats.ResolveDatDir(); if (datDir is null) return; using var dats = new DatCollection(datDir); var source = new DatCollectionAdapter(dats); RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source); Assert.NotNull(snapshot); KeyBindings retailDefaults = KeyBindings.RetailDefaults(); // Aggregate DAT default chords by resolved InputAction — a single action can // be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair). var datChordsByAction = new Dictionary>(); var unresolvedScanCodes = new List(); foreach (RetailActionMapRow row in snapshot!.Rows) { if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action)) continue; if (!datChordsByAction.TryGetValue(action, out HashSet? set)) datChordsByAction[action] = set = new HashSet(); foreach (RetailKeyChord raw in row.DefaultBindings) { Silk.NET.Input.Key? key = RetailScanCodeMap.ToSilkKey(raw.Scan, raw.Device); if (key is null) { unresolvedScanCodes.Add( $"{action}: DAT default scan=0x{raw.Scan:X2} dev={raw.Device} has no " + "RetailScanCodeMap entry"); continue; } set.Add(new KeyChord(key.Value, RetailScanCodeMap.ToModifierMask(raw.Modifier), (byte)raw.Device)); } } Assert.True(datChordsByAction.Count > 100, $"expected >100 mapped actions, got {datChordsByAction.Count}"); Assert.Empty(unresolvedScanCodes); var mismatches = new List(); foreach ((InputAction action, HashSet datChords) in datChordsByAction) { if (KnownRetailDefaultsDisagreements.Contains(action)) continue; var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet(); if (!datChords.SetEquals(acdreamChords)) { mismatches.Add( $"{action}: DAT union=[{string.Join(",", datChords)}] vs " + $"RetailDefaults()=[{string.Join(",", acdreamChords)}]"); } } Assert.True(mismatches.Count == 0, $"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements " + "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n" + string.Join("\n", mismatches)); } }