Skip to content

Win32 — F06: keyboard & keymap ​

Findings from ./examples/f06-keyboard/app.d, the Win32 implementation of the F06 spec: a WndProc that logs the full WM_KEYDOWN → TranslateMessage → WM_CHAR / WM_DEADCHAR → WM_KEYUP chain (plus the WM_SYSKEYDOWN flavor) for input injected at scancode level with SendInput — a letter, a shifted digit, a same-key keydown pair, an Alt chord, a surrogate-pair WM_CHAR, and (after LoadKeyboardLayoutW"00000407") the German-layout scancodes for the Y/Z swap and the dead-acute + E compose. A WSI_NO_TRANSLATE=1 variant skips TranslateMessage in the pump to isolate its exact role. The headline Wine finding: the layout switch is a lie under the headless null driver — the HKL changes, the tables don't — so the de-specific captures are behaviorally proven impossible here and queued for real Windows.

Last reviewed: June 11, 2026

IMPORTANT

Everything observed below is A[wine] — measured under Wine 10.0 (wine64, null display driver, headless) with the exe cross-compiled by LDC 1.41.0 (-mtriple=x86_64-pc-windows-msvc). Wine is a reimplementation, not Windows — and for this feature the gap is structural: Wine's keyboard layout tables come from the display driver, the null driver has only its built-in (US) table, and Wine ships no per-locale kbd*.dll files. Every de-layout claim below is therefore split into observed fallback (A[wine]) and documented Windows contract (cited, queued for the manual-run queue).


The demo ​

Three bounded runs (WSI_AUTO_EXIT=1, ~1.1 s each, all exit 0): default, the WSI_NO_TRANSLATE=1 variant, and an X-backed cross-check (see Surprises). The default run's summary:

text
1122302 summary keydown=11 keyup=11 syskeydown=2 syskeyup=1 char=10 deadchar=0
        syschar=1 sysdeadchar=0 inputlangchange=2

The script (one SetTimer tick per step; each step is one SendInput batch, injected with wVk=0 + KEYEVENTF_SCANCODE so the active layout, not the injector, decides vk and text — "[t]he SendInput function inserts the events in the INPUT structures serially into the keyboard or mouse input stream" (SendInput); the legacy keybd_event wraps the same stream and is documented as superseded by it):

StepScancodes (set 1)Exercises
letter0x1e (A)the plain three-level chain
shifted_digit0x2a + 0x03 (Shift+2)vk ≠ text
repeat_bit0x30, 0x30, up (B)previous-state bit 30
alt_chord0x38 + 0x2e (Alt+C)WM_SYSKEYDOWN/WM_SYSCHAR
load_layout—LoadKeyboardLayoutW("00000407") + probes
de_y_position0x15 (QWERTY Y)Y/Z swap (de types z here)
de_z_position0x2c (QWERTY Z)Y/Z swap, mirror
dead_acute0x0d (´ on de, = on us)WM_DEADCHAR
dead_then_e0x12 (E)the composed WM_CHAR (é on de)
unicode_surrogateKEYEVENTF_UNICODE ×2 unitssurrogate-pair WM_CHARs (U+1F600)

The three-level chain — A[wine] ​

The plain letter, end to end (timestamps µs):

text
123892 key code=0x1e ext=0 vk=0x41 sym=a text=- state=down repeat=0 count=1 sys=0
124395 char utf16=0x0061 cp=U+0061 text=a repeat=0 sys=0
124804 key code=0x1e ext=0 vk=0x41 sym=a text=- state=up repeat=1 count=1 sys=0

code= is bits 16–23 of WM_KEYDOWN's lParam (+ the extended bit 24), vk= is its wParam, sym= is GetKeyNameTextW fed the same lParam bits, count= is bits 0–15, repeat= is bit 30. The three levels split across two messages: the key event carries scancode + vk; the text arrives as a separately posted WM_CHAR — which is why the key line logs text=- and the demo logs char as its own event. (The in-WM_KEYDOWN alternative, calling ToUnicodeEx for the text, is a documented trap: "[a]s ToUnicodeEx translates the virtual-key code, it also changes the state of the kernel-mode keyboard buffer. This state-change affects dead keys, ligatures, Alt+Numeric keypad key entry, and so on" — it would consume the pending accent the nextTranslateMessage needs.)

LevelCarried byProvided by
scancodelParam bits 16–24 of WM_(SYS)KEY*keyboard driver (or the KEYBDINPUT.wScan of an injector)
vkwParam of WM_(SYS)KEY*the active layout's scancode→vk table, applied by the system before posting
texta separate WM_CHAR/WM_DEADCHAR posted by TranslateMessagelayout tables + kernel dead-key state, only if the app pumps TranslateMessage

TranslateMessage's exact role — proven by omission ​

Translates virtual-key messages into character messages. The character messages are posted to the calling thread's message queue, to be read the next time the thread calls the GetMessage or PeekMessage function.

— TranslateMessage, Microsoft Learn

The WSI_NO_TRANSLATE=1 run injects the identical script and skips only the TranslateMessage call in the pump:

text
1121318 summary keydown=12 keyup=12 syskeydown=2 syskeyup=1 char=0 deadchar=0
        syschar=0 sysdeadchar=0 inputlangchange=2

Zero text-level messages. Text on Win32 is not an event the system sends — it is a service the app must request, message by message, in its own pump. A framework that forgets the call (or filters the wrong messages around it) gets scancodes and vks forever and not one character. This also locates dead-key/compose ownership: the accent state machine lives system-side (kernel keyboard buffer + layout tables), but it only advances through the app's TranslateMessage calls.

vk ≠ text: the shifted digit ​

text
223481 key code=0x2a ext=0 vk=0x10 sym=Shift_L text=- state=down repeat=0 count=1 sys=0
223980 key code=0x03 ext=0 vk=0x32 sym=2 text=- state=down repeat=0 count=1 sys=0
224430 char utf16=0x0040 cp=U+0040 text=@ repeat=0 sys=0

vk=0x32 is '2' at both transitions; only the WM_CHAR knows Shift turned it into @. Hotkeys belong to the vk level, text input to the char level — conflating them breaks one or the other (the F06 spec's core point, identical in shape to the sym-vs-text split on the other platforms).

The WM_SYS* flavor ​

Alt-modified keys ride the same chain renamed (A[wine], as documented in WM_SYSKEYDOWN):

text
423110 key code=0x38 ext=0 vk=0x12 sym=Alt_L text=- state=down repeat=0 count=1 sys=1
423601 key code=0x2e ext=0 vk=0x43 sym=c text=- state=down repeat=0 count=1 sys=1
424057 char utf16=0x0063 cp=U+0063 text=c repeat=0 sys=1
424533 key code=0x2e ext=0 vk=0x43 sym=c text=- state=up repeat=1 count=1 sys=1
425009 key code=0x38 ext=0 vk=0x12 sym=Alt_L text=- state=up repeat=1 count=1 sys=0

WM_SYSKEYDOWN → WM_SYSCHAR → WM_SYSKEYUP; the final Alt release arrives as a plain WM_KEYUP (no Alt held anymore). The WM_SYS* messages must reach DefWindowProcW or Alt-menu/Alt-F4 handling dies — the demo forwards them after logging.


The repeat contract — A[wine] + documented ​

The system owns auto-repeat. The app sees it only as extra WM_KEYDOWNs with lParam bit 30 set:

Because of the autorepeat feature, more than one WM_KEYDOWN message may be posted before a WM_KEYUP message is posted. The previous key state (bit 30) can be used to determine whether the WM_KEYDOWN message indicates the first down transition or a repeated down transition.

— WM_KEYDOWN, Microsoft Learn

Injected hardware-level events do not trigger the autorepeat generator, so the demo proves the bit with two keydowns and no intervening release:

text
323778 key code=0x30 ext=0 vk=0x42 sym=b text=- state=down repeat=0 count=1 sys=0
324241 char utf16=0x0062 cp=U+0062 text=b repeat=0 sys=0
324607 key code=0x30 ext=0 vk=0x42 sym=b text=- state=down repeat=1 count=1 sys=0
325069 char utf16=0x0062 cp=U+0062 text=b repeat=1 sys=0

The second down (and its WM_CHAR — the bit propagates into the char message's lParam) carries repeat=1; every WM_KEYUP carries it trivially (the key was down). Bits 0–15 additionally batch coalesced repeats (count=, always 1 here). The knobs are global, read-only-discoverable via SystemParametersInfoW:

text
21762 repeat_config speed=12 delay=1 owner=system

SPI_GETKEYBOARDSPEED is "a value in the range from 0 (approximately 2.5 repetitions per second) through 31 (approximately 30 repetitions per second)"; SPI_GETKEYBOARDDELAY "from 0 (approximately 250 ms delay) through 3 (approximately 1 second delay)" (SystemParametersInfoW). Under Wine the value tracked the host environment (the headless run reported speed=12, an X-backed run speed=31) — the numbers are Wine's, the contract (system repeats; app neither generates nor cancels anything; rate/delay global, not per-app) is Windows'. Contrast Wayland in the F06 spec, where repeat is entirely the client's job.


Layouts: the switch that lies under Wine — A[wine], the headline finding ​

Step load_layout requests German and verifies behaviorally rather than trusting the returned handle:

text
522976 script step=load_layout klid=00000407
523795 msg name=WM_INPUTLANGCHANGE charset=0 hkl=0x4070409
524158 layout_active hkl=0x4070409 klid=04070409 tables=fallback_not_de
524553 layout_map scan=0x15 vk_start=0x59 vk_de=0x59
524864 layout_map scan=0x2c vk_start=0x5a vk_de=0x5a
525593 tounicodeex layout=de hkl=0x4070409 scan=0x15 vk=0x59 rc=1 text=y
527208 tounicodeex layout=de hkl=0x4070409 scan=0x0d vk=0xbb rc=1 text==

Everything that signals success fires: LoadKeyboardLayoutW returns non-null, WM_INPUTLANGCHANGE is delivered, GetKeyboardLayoutNameW reports 04070409. But the HKL exposes the trick — the name "is a string composed of the hexadecimal value of the Language Identifier (low word) and a device identifier (high word)" (LoadKeyboardLayoutW), and Wine glued the German language id onto the unchanged mapping: MapVirtualKeyExW still maps scan 0x15 → vk 0x59 (Y) under the "de" HKL, and the injected scancodes typed y/z US-style with no Y/Z swap. The dead-acute position produced an ordinary = (tounicodeex … rc=1 text==, where rc=-1 would mark a dead key per ToUnicodeEx: "[t]he specified virtual key is a dead key character (accent or diacritic)"). Root cause: Wine builds layout tables in the display driver from the host keymap; the null driver carries only the built-in default table, and no kbd*.dll per-locale tables exist in the Wine 10 tree to load.

Consequences recorded as findings:

  • A binding cannot verify a layout switch from the HKL/WM_INPUTLANGCHANGE paper trail; the only honest check is behavioral (map a known scancode through the new HKL, as the demo's tables= verdict does). This holds on Windows too — HKL values are explicitly "device identifiers", not table identities.
  • The de-specific spec items — Y/Z swap on injection, and the dead-key capture — are structurally impossible under headless Wine, not merely flaky: queued for the manual-run queue (real-Windows CI), where the same binary and script need no changes.

The dead-key sequence: fallback captured, contract cited ​

What the run captured (us tables, so 0x0d is =, no dead key — the chain stays two-level):

text
822849 key code=0x0d ext=0 vk=0xbb sym== text=- state=down repeat=0 count=1 sys=0
823339 char utf16=0x003d cp=U+003D text== repeat=0 sys=0
923215 key code=0x12 ext=0 vk=0x45 sym=E text=- state=down repeat=0 count=1 sys=0
923665 char utf16=0x0065 cp=U+0065 text=e repeat=0 sys=0

What the same code logs where de tables exist (documented contract — the demo's deadchar handler is exercised the moment it runs on real Windows):

The window with the keyboard focus would receive the following sequence of messages: WM_KEYDOWN, WM_DEADCHAR, WM_KEYUP, WM_KEYDOWN, WM_CHAR, WM_KEYUP … TranslateMessage generates the WM_DEADCHAR message when it processes the WM_KEYDOWN message from a dead key.

— About Keyboard Input § dead keys (Microsoft Learn; the German circumflex + o example)

i.e. for the demo's acute + E: key code=0x0d state=down → deadchar utf16=0x00b4 → key code=0x0d state=up → key code=0x12 state=down → char utf16=0x00e9 text=é → key code=0x12 state=up, with WM_DEADCHAR carrying the accent itself ("WM_DEADCHAR specifies a character code generated by a dead key" — WM_DEADCHAR) and the composed é arriving only at the text level — scancode and vk levels never see a composed character.


Surrogate pairs: one code point, two WM_CHARs — A[wine] ​

KEYEVENTF_UNICODE injection of U+1F600 (😀) arrives as vk=0xe7 (VK_PACKET) key events plus one WM_CHAR per UTF-16 unit, which the app must recombine:

text
1023608 key code=0x3d ext=0 vk=0xe7 sym=F3 text=- state=down repeat=1 count=1 sys=0
1024073 char_unit utf16=0xd83d note=high_surrogate_pending
1024914 key code=0x00 ext=0 vk=0xe7 sym= text=- state=down repeat=1 count=1 sys=0
1025364 char utf16=0xde00 cp=U+1F600 text=😀 repeat=0 sys=0

A WM_CHAR handler that treats wParam as a character (rather than a UTF-16 code unit with pending-high-surrogate state, as WM_CHAR documents) corrupts every supplementary-plane input. Wine quirk worth flagging: the KEYBDINPUT.wScan UTF-16 unit leaks into the key message's scancode bits (code=0x3d = the low byte of 0xd83d, rendering a bogus sym=F3) — scancode fields are garbage for VK_PACKET events (A[wine]; KEYBDINPUT documents wScan as repurposed for the unit, so this is likely Windows-faithful, but it is queued for confirmation).


Surprises ​

  • Every success signal of a layout switch can fire while the tables stay put (A[wine], the headline finding above). Behavioral verification or nothing.
  • An X-backed cross-check didn't help: under xvfb-run (Wine's winex11 driver — recognizable in the log by key names switching from Shift_L to Shift) with a de keymap uploaded via xkbcomp, the run still reported tables=fallback_not_de and speed=31 instead of the headless 12 — Wine 10's layout tracking of a post-connection X keymap change is at best unreliable. Not pursued further; real Windows is the cheaper verifier (manual queue).
  • text=- is not a demo limitation but the platform's shape: Win32 itself delivers text as a separate, later message, and the one API that could fill it in synchronously (ToUnicodeEx) is documented to corrupt the dead-key state that TranslateMessage depends on.
  • WM_INPUTLANGCHANGE arrived once before any layout work (charset=254, hkl=0x4090409, ~33 ms in) — an initial-activation notification under Wine. A binding keying "user switched layouts" UI off this message needs to tolerate a spurious one at startup (A[wine]).
  • The keyup messages always carry repeat=1 — bit 30 is "previous key state", not "is autorepeat"; only on WM_KEYDOWN does it mean repeat. Reading it uniformly across down/up (as the log field tempts) would misclassify every release.

Build & run — A[wine] ​

The scaffold's verified pipeline, run in docs/research/window-system-integration/os-apis/win32/examples/f06-keyboard/:

bash
nix develop .#win32 -c win32-ldc2 app.d instrument.d -of=build/f06-keyboard.exe
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 \
    nix develop .#win32 -c wine64 ./build/f06-keyboard.exe                    # default
WINEPREFIX=$(mktemp -d) WINEDEBUG=-all WSI_AUTO_EXIT=1 WSI_NO_TRANSLATE=1 \
    nix develop .#win32 -c wine64 ./build/f06-keyboard.exe                    # no TranslateMessage

Both exit 0 in ~1.2 s. Without WSI_AUTO_EXIT=1 the script still runs, then the window stays open and logs real typing (interactive layout/AltGr exploration on real Windows). The package's dub.sdl (platforms "windows") exists for the Windows CI runner; locally dub is not part of the pipeline, and the demo must not be run under wine explorer /desktop=… (it swallows stdout and the exit code).


Sources ​