fix(input): keys are physical positions on every layout; Input.key_label names them

The Windows runtime keyed the held set by the character MapVirtualKeyA gave a
virtual key, so a game's WASD belonged to whatever the active layout put there,
and with an input method on every letter arrived as VK_PROCESSKEY. The typing
block is now read from the scancode (the arrows, numpad and F-keys still by
virtual key); macOS reads keyCode the same way. Input.key_label(key) names a key
in the player's own layout (Windows) or as its US character elsewhere.

Verified on Windows with SendInput into a live window: VK_Z carrying W's scancode
holds 'w', VK_PROCESSKEY carrying A's holds 'a', Caps Lock changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 23:26:53 +03:00
parent 2a2f463d5b
commit 619476ffed
14 changed files with 60552 additions and 60168 deletions

View file

@ -333,11 +333,35 @@ ret:
; keyDown switch: arrows -> WASD, return, escape -> 27, else the first character).
; #88: escape delivers key 27 (left to the game), not 'q' — so the held-key set
; stays consistent with @W_key and a shipped game owns Esc (pause).
; A key in the typing block is its PHYSICAL position, named for what it types on a US
; layout (win32.ll keys its held set the same way): keyCode is already a position, so the
; kVK_ANSI_* codes map straight through and AZERTY's Z-labelled key is 'w' like everywhere
; else. Anything outside the table falls back to the character it types.
@C_kc2key = internal constant [51 x i8] [i8 97, i8 115, i8 100, i8 102, i8 104, i8 103, i8 122, i8 120, i8 99, i8 118, i8 0, i8 98, i8 113, i8 119, i8 101, i8 114, i8 121, i8 116, i8 49, i8 50, i8 51, i8 52, i8 54, i8 53, i8 61, i8 57, i8 55, i8 45, i8 56, i8 48, i8 93, i8 111, i8 117, i8 91, i8 105, i8 112, i8 0, i8 108, i8 106, i8 39, i8 107, i8 59, i8 92, i8 44, i8 47, i8 110, i8 109, i8 46, i8 0, i8 0, i8 96]
; what the active layout types on a position: not read on macOS yet, so the caller names
; the key after its US position
define i32 @win_key_char(i32 %k) {
entry:
ret i32 0
}
define i32 @ev_keyval(ptr %ev) {
entry:
%sel_kc = call ptr @sel_registerName(ptr @.s_keycd)
%kc = call i16 (ptr, ptr) @objc_msgSend(ptr %ev, ptr %sel_kc)
%kc32 = zext i16 %kc to i32
%inpos = icmp ult i32 %kc32, 51
br i1 %inpos, label %bypos, label %special
bypos:
%pp = getelementptr [51 x i8], ptr @C_kc2key, i64 0, i32 %kc32
%p8 = load i8, ptr %pp
%pos = zext i8 %p8 to i32
%named = icmp ne i32 %pos, 0
br i1 %named, label %haspos, label %special
haspos:
ret i32 %pos
special:
switch i32 %kc32, label %chars [
i32 126, label %vw
i32 125, label %vs

View file

@ -372,6 +372,46 @@ function input_key_down(k: int) -> bool { in_init(); return in_bit_get(in_h
function input_key_pressed(k: int) -> bool { in_init(); return in_bit_get(in_held, k) and (not in_bit_get(in_prev, k)) }
function input_key_released(k: int) -> bool { in_init(); return (not in_bit_get(in_held, k)) and in_bit_get(in_prev, k) }
# The name to show a player for key code `k`. A letter, digit or punctuation code is a
# PHYSICAL key - the one that types it on a US layout - so the name is what the player's
# own layout types there: 'w' reads "W" on QWERTY and "Z" on AZERTY, and a binding
# shown as "W" is never a key the player cannot find. Named keys and the arrows get words.
# Headless, and on a platform that cannot ask the layout, it is the US character.
function input_key_label(k: int) -> string {
if k == 32 { return "Space" }
if k == 10 { return "Enter" }
if k == 27 { return "Esc" }
if k == 9 { return "Tab" }
if k == 8 { return "Backspace" }
if k == 16 { return "Shift" }
if k == 17 { return "Ctrl" }
if k == 18 { return "Alt" }
if k == 128 { return "Up" }
if k == 129 { return "Down" }
if k == 130 { return "Left" }
if k == 131 { return "Right" }
var c = 0
if is_windowed() { c = win_key_char(k) }
if c <= 0 and k > 32 and k < 127 {
c = k
if c >= 97 and c <= 122 { c = c - 32 }
}
if c <= 0 { return "" }
return in_utf8(c)
}
# one code point as a UTF-8 string
function in_utf8(c: int) -> string {
let out = bytes(5)
var n = 0
if c < 128 { out[0] = c; n = 1 }
else if c < 2048 { out[0] = 192 | (c >> 6); out[1] = 128 | (c & 63); n = 2 }
else if c < 65536 { out[0] = 224 | (c >> 12); out[1] = 128 | ((c >> 6) & 63); out[2] = 128 | (c & 63); n = 3 }
else { out[0] = 240 | (c >> 18); out[1] = 128 | ((c >> 12) & 63); out[2] = 128 | ((c >> 6) & 63); out[3] = 128 | (c & 63); n = 4 }
out[n] = 0
let s: string = out
return s
}
# inject a held key (AI, tutorial, testing, network) — persists until released.
function input_press(k: int) -> void { in_init(); in_bit_set(in_sim, k, true) }
function input_release(k: int) -> void { in_init(); in_bit_set(in_sim, k, false) }

View file

@ -17,7 +17,12 @@
; held keys a 256-bit set of Ludic key codes: letters lowercase, digits and
; punctuation as their unshifted character, Space 32, Enter 10,
; Esc 27, Backspace 8, Tab 9, Shift 16, Ctrl 17, Alt 18, and the
; arrows at 128 129 130 131 (up down left right)
; arrows at 128 129 130 131 (up down left right). A key in the
; typing block is its PHYSICAL position, named for what it types
; on a US layout: 'w' is the key right of Tab's row start on every
; layout, language, input method and Caps Lock state
; key char win_key_char(code): what the active layout types on that key,
; upper case, 0 when it types nothing or the code is not a position
; frame key the last key pressed this frame, arrows reported as w s a d
; mouse framebuffer pixels (client pixels / scale), top-left origin;
; buttons bit0 left, bit1 right; wheel in notches, up positive
@ -27,8 +32,13 @@
; backing scale is 1, and a 1280x720 window is 1280x720 pixels on any display.
;
; Keys are read from the message queue before dispatch rather than from WM_CHAR:
; TranslateMessage is never called, so no character messages are produced, and a
; key's character comes from MapVirtualKeyA. The system keys (Alt, F10) are never
; TranslateMessage is never called, so no character messages are produced. The held
; set is keyed by the scancode in lParam, not by the virtual key: a virtual key is
; the layout's idea of the key, so on AZERTY the key where W is printed on a US
; board arrives as VK_Z, and an input method turns every letter into VK_PROCESSKEY
; (229) - WASD simply stopped existing for those players. Keys outside the table
; (arrows, the numpad, F-keys, the ISO key) still go by virtual key, and the frame
; key keeps the layout's character, because that one is typing. The system keys (Alt, F10) are never
; handed to DefWindowProc, whose menu loop would stall the game's frame loop -
; Alt+F4 is honoured here instead.
; ============================================================================
@ -56,6 +66,8 @@ declare i32 @IsWindow(ptr)
declare ptr @SetCapture(ptr)
declare i32 @ReleaseCapture()
declare i32 @MapVirtualKeyA(i32, i32)
declare i32 @MapVirtualKeyW(i32, i32)
declare ptr @CharUpperW(ptr)
declare i16 @GetKeyState(i32)
declare i32 @RegisterRawInputDevices(ptr, i32, i32)
declare i32 @GetRawInputData(ptr, i32, ptr, ptr, i32)
@ -306,9 +318,32 @@ entry:
ret i32 %c7
}
; the held-set code of a virtual key (cocoa.ll's ev_keyval)
define internal i32 @w_keyval(i64 %vk64) {
; The typing block by position: a set-1 scancode (non-extended) to the key code it is on a
; US layout, and back. 0 is a key the table does not name.
@W_sc2key = internal constant [128 x i8] [i8 0, i8 0, i8 49, i8 50, i8 51, i8 52, i8 53, i8 54, i8 55, i8 56, i8 57, i8 48, i8 45, i8 61, i8 0, i8 0, i8 113, i8 119, i8 101, i8 114, i8 116, i8 121, i8 117, i8 105, i8 111, i8 112, i8 91, i8 93, i8 0, i8 0, i8 97, i8 115, i8 100, i8 102, i8 103, i8 104, i8 106, i8 107, i8 108, i8 59, i8 39, i8 96, i8 0, i8 92, i8 122, i8 120, i8 99, i8 118, i8 98, i8 110, i8 109, i8 44, i8 46, i8 47, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0]
@W_key2sc = internal constant [128 x i8] [i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 40, i8 0, i8 0, i8 0, i8 0, i8 51, i8 12, i8 52, i8 53, i8 11, i8 2, i8 3, i8 4, i8 5, i8 6, i8 7, i8 8, i8 9, i8 10, i8 0, i8 39, i8 0, i8 13, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 26, i8 43, i8 27, i8 0, i8 0, i8 41, i8 30, i8 48, i8 46, i8 32, i8 18, i8 33, i8 34, i8 35, i8 23, i8 36, i8 37, i8 38, i8 50, i8 49, i8 24, i8 25, i8 16, i8 19, i8 31, i8 20, i8 22, i8 47, i8 17, i8 45, i8 21, i8 44, i8 0, i8 0, i8 0, i8 0, i8 0]
; the held-set code of a key message: the scancode's position when the table names it,
; the virtual key otherwise (cocoa.ll's ev_keyval)
define internal i32 @w_keyval(i64 %vk64, i64 %lp) {
entry:
%sc64 = lshr i64 %lp, 16
%sc = and i64 %sc64, 255
%ext64 = lshr i64 %lp, 24
%ext = and i64 %ext64, 1
%plain = icmp eq i64 %ext, 0
%inrange = icmp ult i64 %sc, 128
%lookup = and i1 %plain, %inrange
br i1 %lookup, label %bypos, label %byvk
bypos:
%sp = getelementptr [128 x i8], ptr @W_sc2key, i64 0, i64 %sc
%s8 = load i8, ptr %sp
%pos = zext i8 %s8 to i32
%named = icmp ne i32 %pos, 0
br i1 %named, label %haspos, label %byvk
haspos:
ret i32 %pos
byvk:
%vk = trunc i64 %vk64 to i32
switch i32 %vk, label %chars [
i32 38, label %up
@ -347,6 +382,39 @@ chars:
ret i32 %f
}
; What the active layout types on the key at held-set code %k, upper case: 'W' for 'w' on a
; US layout, 'Z' on AZERTY, 0x131 for 'i' on Turkish F... 0 when %k is not a position in the
; table or the key types nothing. A dead key reports its accent; the top bit that marks it is
; dropped.
define i32 @win_key_char(i32 %k) {
entry:
%inrange = icmp ult i32 %k, 128
br i1 %inrange, label %look, label %none
look:
%kp = getelementptr [128 x i8], ptr @W_key2sc, i64 0, i32 %k
%s8 = load i8, ptr %kp
%sc = zext i8 %s8 to i32
%has = icmp ne i32 %sc, 0
br i1 %has, label %map, label %none
map:
%vk = call i32 @MapVirtualKeyW(i32 %sc, i32 1) ; MAPVK_VSC_TO_VK
%c = call i32 @MapVirtualKeyW(i32 %vk, i32 2) ; MAPVK_VK_TO_CHAR
%c16 = and i32 %c, 65535
%types = icmp ne i32 %c16, 0
br i1 %types, label %up, label %none
up:
; CharUpperW takes a single character in the low word of its pointer argument
%cz = zext i32 %c16 to i64
%cp = inttoptr i64 %cz to ptr
%uq = call ptr @CharUpperW(ptr %cp)
%ui = ptrtoint ptr %uq to i64
%u16 = and i64 %ui, 65535
%u = trunc i64 %u16 to i32
ret i32 %u
none:
ret i32 0
}
; the frame key of a virtual key (cocoa.ll's win_poll): arrows as wasd, a letter in the
; case Shift gives it, and 0 for a key that types nothing
define internal i32 @w_framekey(i64 %vk64) {
@ -422,7 +490,7 @@ handle:
i32 255, label %raw ; WM_INPUT
]
kdown:
%hv = call i32 @w_keyval(i64 %wp)
%hv = call i32 @w_keyval(i64 %wp, i64 %lp)
call void @win_held_bit(i32 %hv, i32 1)
%isf4 = icmp eq i64 %wp, 115 ; VK_F4
%issys = icmp eq i32 %m, 260
@ -439,7 +507,7 @@ setfk:
store i32 %fk, ptr @W_key
br label %pump
kup:
%uv = call i32 @w_keyval(i64 %wp)
%uv = call i32 @w_keyval(i64 %wp, i64 %lp)
call void @win_held_bit(i32 %uv, i32 0)
br label %pump
ldown:

View file

@ -303,6 +303,9 @@ async function start({ wasm, canvas, manifest = null, files = {}, onExit = null,
win_mouse: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 4; i++) host.u32[b + i] = 0; },
win_pad: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 24; i++) host.u32[b + i] = 0; },
win_touch: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 24; i++) host.u32[b + i] = 0; },
// what the layout types on a key position: not read in the browser, so Input.key_label
// names the key after its US position
win_key_char: (k) => 0,
};
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasm), { env });