Read the client's key enum out of WoW.exe — the fact KEY_DOWN was blocked on

`KEY_DOWN`/`KEY_UP` were the last two nampower events left unfired, and the blocker was never
effort: it was the client's numeric `KEY_*` enum. The DLL's own strings publish four values
(`KEY_NONE=-1`, `KEY_SHIFT=0`, `KEY_A=65`, `KEY_ESCAPE=512`, `KEY_F1=768`) — enough to see three
banks and not enough to name a key past the first in any of them, and a guessed code is worse than
no event, since an addon switching on `arg1` would silently never match.

It is in the binary, in the name parser `SetBinding` resolves a key string through (`0x4886f0`),
and the arithmetic is in the instructions rather than inferred:

- **ASCII, bank 0** — a printable key is its own byte (`movsbl (%esi)`), which is why `KEY_A` is 65.
- **Numpad, `0x100`** — the digit character plus `0xd0` (`add $0xd0,%edi` at `0x48875b`), so
  `NUMPAD0 = 0x100`..`NUMPAD9 = 0x109`, then `+ - * / .` at `0x10a`..`0x10e`, each reached by its
  own character compare.
- **Specials, `0x200`** — contiguous from `ESCAPE = 0x200` through `PAGEDOWN = 0x20d`, then a hole,
  `NUMLOCK = 0x20f`, two more holes, `PRINTSCREEN = 0x212`. Every one is a `mov $imm,%edi` in the
  chain at `0x4888a0`..`0x488a20`; the holes are codes this parser assigns to no name.
- **Function keys, `0x300`** — `0x2ff + n` (`add $0x2ff,%edi`), so `F1 = 768` as published, and
  `NUMPADEQUALS` sits past them at `0x30c`, from the parser's own `=` arm.

Transcribed into `bindings::chord::key_code`, which composes with the existing `key_token`, with a
test per bank and the three published values as its anchors. `CAPSLOCK`, `SPACE` and the
mouse-wheel names are in the client's name table at `0x846c20` but take no code from this parser,
so they answer `None` rather than a number nobody can check — and a second test walks every token
`key_token` can produce and fails on any that has neither a code nor a written-down reason.

No caller yet, and that is the honest state: what is left for the events is a **producer** on the
keyboard path, which is nampower's hook and the same hook the press-interception queue uses, so it
belongs with that decision rather than ahead of it. `docs/ADDON_COMPAT.md` now says so instead of
naming the enum as the blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-31 18:10:36 -06:00
co-authored by Claude Opus 5
parent a9ed091ebe
commit 9df4b6abd8
2 changed files with 197 additions and 1 deletions
+196
View File
@@ -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<d>` 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<n>` 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<i32> {
// 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::<i32>().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<d>` 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"
);
}
}
+1 -1
View File
@@ -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