diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs index f64e9a75..9263e726 100644 --- a/crates/benilla-app/src/bindings/chord.rs +++ b/crates/benilla-app/src/bindings/chord.rs @@ -223,6 +223,83 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> { }) } +/// The client's own numeric key code for a [`key_token`] name, or `None` for a name it has none +/// for. +/// +/// Transcribed from the reference's name parser at `0x4886f0`, the function `SetBinding` resolves a +/// key string through. Four banks, and the arithmetic is in the instructions rather than inferred: +/// +/// - **ASCII, bank 0.** A printable key is its own byte: the parser reads `movsbl (%esi)` and uses +/// it directly, which is why the DLL's own strings publish `KEY_A = 65`. +/// - **Numpad, bank `0x100`.** `NUMPAD` is the digit character plus `0xd0` +/// (`add $0xd0,%edi` at `0x48875b`), so `NUMPAD0 = 0x100` through `NUMPAD9 = 0x109`. Then the +/// operators, each reached by its own character compare: `+` → `0x10a`, `-` → `0x10b`, +/// `*` → `0x10c`, `/` → `0x10d`, `.` → `0x10e`. +/// - **Specials, bank `0x200`.** `ESCAPE = 0x200` and the run is contiguous from there: +/// `ENTER`, `BACKSPACE`, `TAB`, `LEFT`, `UP`, `RIGHT`, `DOWN`, `INSERT`, `DELETE`, `HOME`, +/// `END`, `PAGEUP`, `PAGEDOWN`, then a hole at `0x20e`, `NUMLOCK = 0x20f`, two more holes, and +/// `PRINTSCREEN = 0x212`. Every one of those is a `mov $imm,%edi` in the chain at +/// `0x4888a0`..`0x488a20`; the holes are codes this parser assigns to no name. +/// - **Function keys, bank `0x300`.** `F` is `0x2ff + n` (`add $0x2ff,%edi` at `0x4886f7`), so +/// `F1 = 0x300` — the DLL's published `KEY_F1 = 768` — through `F12 = 0x30b`. `NUMPADEQUALS` +/// sits just past them at `0x30c`, which is the parser's own `=` arm (`0x488820`), not a guess. +/// +/// `CAPSLOCK`, `SPACE` and the mouse-wheel names are in the client's **name** table at `0x846c20` +/// but take no code from this parser: `SPACE` is ASCII `0x20` and needs none, and the other two the +/// parser never reaches. They answer `None` here rather than a number nobody can check. +/// +/// This is the fact `KEY_DOWN`/`KEY_UP` were blocked on — see `docs/ADDON_COMPAT.md`. It is not +/// the events themselves: those need a producer on the keyboard path, which is nampower's hook and +/// belongs with that decision. +// No caller yet, and deliberately so: the consumer is `KEY_DOWN`/`KEY_UP`, whose *producer* is +// nampower's keyboard hook and is a separate decision (`docs/ADDON_COMPAT.md`). The table is the +// fact those events were blocked on, and it is landed with its test now rather than read twice. +#[allow(dead_code)] +pub(crate) fn key_code(token: &str) -> Option { + // The function keys first: `F1`..`F24` must not be read as the ASCII `F`. + if let Some(n) = token.strip_prefix('F').and_then(|n| n.parse::().ok()) { + return (n >= 1).then_some(0x2ff + n); + } + if let Some(rest) = token.strip_prefix("NUMPAD") { + return Some(match rest { + "PLUS" => 0x10a, + "MINUS" => 0x10b, + "MULTIPLY" => 0x10c, + "DIVIDE" => 0x10d, + "DECIMAL" => 0x10e, + "EQUALS" => 0x30c, + d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() => { + i32::from(d.as_bytes()[0]) + 0xd0 + } + _ => return None, + }); + } + let special = |code: i32| Some(code); + match token { + "ESCAPE" => special(0x200), + "ENTER" => special(0x201), + "BACKSPACE" => special(0x202), + "TAB" => special(0x203), + "LEFT" => special(0x204), + "UP" => special(0x205), + "RIGHT" => special(0x206), + "DOWN" => special(0x207), + "INSERT" => special(0x208), + "DELETE" => special(0x209), + "HOME" => special(0x20a), + "END" => special(0x20b), + "PAGEUP" => special(0x20c), + "PAGEDOWN" => special(0x20d), + "NUMLOCK" => special(0x20f), + "PRINTSCREEN" => special(0x212), + // One ASCII byte is its own code; anything longer is a name this parser has none for. + t => { + let b = t.as_bytes(); + (b.len() == 1 && b[0].is_ascii_graphic()).then(|| i32::from(b[0])) + } + } +} + /// Fold key aliases that share one 1.12 token (`NumpadEnter` → `Enter`) so a chord parsed from /// `ENTER` matches either physical key. pub(crate) fn normalize_key(k: KeyCode) -> KeyCode { @@ -640,4 +717,123 @@ mod tests { } ); } + + /// Every bank of the client's key enum, against the four values the nampower DLL's own strings + /// publish and the immediates in the parser at `0x4886f0`. + #[test] + fn the_key_code_banks_are_the_references_own() { + // The DLL publishes these four, which is what pins the banks. + assert_eq!(key_code("A"), Some(65), "KEY_A"); + assert_eq!(key_code("ESCAPE"), Some(512), "KEY_ESCAPE"); + assert_eq!(key_code("F1"), Some(768), "KEY_F1"); + + // ASCII is the byte itself, so the digits are not the numpad bank. + assert_eq!(key_code("0"), Some(0x30)); + assert_eq!(key_code("9"), Some(0x39)); + assert_eq!(key_code("Z"), Some(0x5a)); + + // `NUMPAD` is the digit character plus `0xd0`. + assert_eq!(key_code("NUMPAD0"), Some(0x100)); + assert_eq!(key_code("NUMPAD9"), Some(0x109)); + assert_eq!(key_code("NUMPADPLUS"), Some(0x10a)); + assert_eq!(key_code("NUMPADMINUS"), Some(0x10b)); + assert_eq!(key_code("NUMPADMULTIPLY"), Some(0x10c)); + assert_eq!(key_code("NUMPADDIVIDE"), Some(0x10d)); + assert_eq!(key_code("NUMPADDECIMAL"), Some(0x10e)); + // …and the one that sits past the function keys rather than with its own bank. + assert_eq!(key_code("NUMPADEQUALS"), Some(0x30c)); + + // The specials run contiguously from ESCAPE, with the holes the parser leaves. + for (token, code) in [ + ("ENTER", 0x201), + ("BACKSPACE", 0x202), + ("TAB", 0x203), + ("LEFT", 0x204), + ("UP", 0x205), + ("RIGHT", 0x206), + ("DOWN", 0x207), + ("INSERT", 0x208), + ("DELETE", 0x209), + ("HOME", 0x20a), + ("END", 0x20b), + ("PAGEUP", 0x20c), + ("PAGEDOWN", 0x20d), + ("NUMLOCK", 0x20f), + ("PRINTSCREEN", 0x212), + ] { + assert_eq!(key_code(token), Some(code), "{token}"); + } + + // F1..F12 are the bank; the parser's `add $0x2ff` admits higher ones too. + assert_eq!(key_code("F12"), Some(0x30b)); + assert_eq!(key_code("F24"), Some(0x317)); + assert_eq!(key_code("F0"), None, "there is no F0 to bind"); + + // Named keys the parser reaches no arm for answer None rather than a guess. + assert_eq!(key_code("CAPSLOCK"), None); + assert_eq!(key_code("MOUSEWHEELUP"), None); + assert_eq!(key_code("BUTTON1"), None); + // `SPACE` is ASCII `0x20`, but the token is the word, and the word has no code of its + // own. The character does not either: `0x20` is not `is_ascii_graphic`, so the ASCII arm + // declines it rather than handing back a code for a key the parser treats separately. + assert_eq!(key_code("SPACE"), None); + assert_eq!(key_code(" "), None); + } + + /// Every token `key_token` can produce either has a code or is one of the four named absences. + #[test] + fn every_token_the_binder_produces_is_accounted_for() { + use bevy::input::keyboard::KeyCode; + let mut unaccounted = Vec::new(); + for k in [ + KeyCode::KeyA, + KeyCode::Digit1, + KeyCode::F1, + KeyCode::F12, + KeyCode::Space, + KeyCode::Tab, + KeyCode::Enter, + KeyCode::Escape, + KeyCode::Backspace, + KeyCode::Insert, + KeyCode::Delete, + KeyCode::Home, + KeyCode::End, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::ArrowUp, + KeyCode::ArrowDown, + KeyCode::ArrowLeft, + KeyCode::ArrowRight, + KeyCode::Numpad0, + KeyCode::NumpadAdd, + KeyCode::NumpadSubtract, + KeyCode::NumpadMultiply, + KeyCode::NumpadDivide, + KeyCode::NumpadDecimal, + KeyCode::NumLock, + KeyCode::CapsLock, + KeyCode::Minus, + KeyCode::Equal, + KeyCode::BracketLeft, + KeyCode::Backslash, + KeyCode::Semicolon, + KeyCode::Quote, + KeyCode::Comma, + KeyCode::Period, + KeyCode::Slash, + KeyCode::Backquote, + ] { + let Some(token) = key_token(k) else { continue }; + if key_code(token).is_none() { + unaccounted.push(token); + } + } + assert_eq!( + unaccounted, + // `SPACE` and `CAPSLOCK` are in the client's name table with no code from this parser. + vec!["SPACE", "CAPSLOCK"], + "a token with neither a code nor a written-down reason" + ); + } } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 7b6de43b..1a4d5b2a 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -1257,7 +1257,7 @@ from the consumer side — it destructures `arg2`=spellId, `arg3`=casterGuid, `a | **Fired** | the aura eight — `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`) — the cast ten: `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE` (`ui_cast_events.rs`), `SPELL_CAST_EVENT` (`ui_self_cast.rs`), and `SPELL_QUEUE_EVENT` (`ui_spellbook.rs`, off `spell/queue.rs`'s own rows — every mutation of the queue goes through `queue`/`pop`, so a code cannot be dropped by a caller that forgets to emit one) | | **Deliberately absent** | `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` — diffing the helpful half of every streamed unit, which nothing registers (the `AuraEdge` doc says so at the enum); `SPELL_DELAYED_OTHER` — upstream's own note is that the server only sends the packet to the affected player, so it never fires there either | -| **To fire, needs its own producer** | `KEY_DOWN`/`KEY_UP`, and nothing else — see "nampower's combat-log family" below for the other thirty-five, which are now fired. The pair needs the client's own numeric `KEY_*` enum, which is a table in `WoW.exe` this round did not read: the DLL's strings publish four of its values (`KEY_NONE=-1`, `KEY_SHIFT=0`, `KEY_A=65`, `KEY_ESCAPE=512`, `KEY_F1=768`), enough to see the shape — ASCII in bank 0, specials at `0x200`, function keys at `0x300` — and not enough to name a key in bank 2 past the first. A guessed code is worse than no event: an addon switching on `arg1` would silently never match. They are also nampower's **keyboard hook**, the same hook the press-interception queue uses, so they belong with that decision rather than ahead of it | +| **To fire, needs its own producer** | `KEY_DOWN`/`KEY_UP`, and nothing else — see "nampower's combat-log family" below for the other thirty-five, which are now fired. **The enum is no longer the blocker.** It was read out of `WoW.exe` on 2026-10-31, from the name parser `SetBinding` resolves a key string through (`0x4886f0`), and transcribed into `bindings::chord::key_code` with a test per bank: ASCII is the byte itself (`KEY_A = 65`); the numpad bank is the digit plus `0xd0` (`NUMPAD0 = 0x100`) with `+ - * / .` at `0x10a`..`0x10e`; the specials run contiguously from `ESCAPE = 0x200` to `PAGEDOWN = 0x20d`, then `NUMLOCK = 0x20f` and `PRINTSCREEN = 0x212` with holes between; the function keys are `0x2ff + n` (`KEY_F1 = 768`), and `NUMPADEQUALS` sits past them at `0x30c`. `CAPSLOCK`, `SPACE` and the mouse-wheel names take no code from that parser and answer `None` rather than a guess. What is left is the **producer**: the pair is nampower's keyboard hook, the same hook the press-interception queue uses, so firing them belongs with that decision rather than ahead of it | ### nampower's combat-log family — 35 events off a decode that was already there