Let a control map listen to any keyboard, and fix the dead arrow keys

The sidescroller's controls did nothing. `akgl_controller_handle_event` matched
`event->key.which == curmap->kbid` exactly, with no way to say "whatever
keyboard the player is typing on", so the game did the obvious thing and bound
`SDL_GetKeyboards()[0]`.

That cannot work. The id a key event *carries* is chosen by the video backend
and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without
XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while
`SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`,
which is 1; with XInput2 the events carry the physical slave device's
`sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map
matched nothing and every key press was dropped.

A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend:
SDL documents `which` as 0 when the source is unknown or virtual, and joystick
ids start at 1, so no real device is 0. A non-zero id still matches only that
device, which is what keeps two local players on two keyboards apart, and there
is a test for that half too. `util/charviewer.c` already passed 0 and depended
on the old behaviour by accident; it works under XInput2 now as well.

Two reasons this shipped, both closed:

- Every test in tests/controller.c dispatched an event whose id equalled the id
  the map was bound with, so none of them could see it.
- The sidescroller's smoke test called the control handlers directly instead of
  dispatching events, so it passed against a control map that matched nothing.
  It now pushes synthetic events through akgl_controller_handle_event from a
  deliberately non-zero device id and fails if the press does not arrive.
  Verified by breaking the binding and watching the run exit non-zero.

`test_controller_wildcard_device_ids` was written first and failed against the
unfixed library with "a map bound to keyboard 0 ignored a key press from device
11", which is the whole reason to trust it now that it passes.

The controls were documented, in one sentence. Chapter 19 has a proper table of
them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The
header said the match was exact and now says what it does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:23:02 -04:00
parent 15ac5d5dd0
commit 943bf4324e
8 changed files with 233 additions and 53 deletions

View File

@@ -122,8 +122,29 @@ defaults are already in slots 07 and your additions land after them.
A match requires **all three** of: the event type equals the binding's `event_on` or
`event_off`; the device id on the event equals the map's `kbid` (keyboard events) or `jsid`
(gamepad events); and the key or button equals the binding's. The device-id half is what
keeps two players on two keyboards apart, and it is exact — `akgl_controller_list_keyboards`
logs every attached keyboard and its id, which is how you find the number to pass.
keeps two players on two keyboards apart — `akgl_controller_list_keyboards` logs every
attached keyboard and its id, which is how you find the number to pass.
### Bind device 0 unless you mean a specific device
**A `kbid` or `jsid` of `0` matches every device of that kind**, and it is what a
single-player game wants.
Do not reach for `SDL_GetKeyboards()` to fill it in. That call reports what is *attached*;
the id a key event actually *carries* is chosen by the video backend, and the two need not
agree. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is
`0`, while SDL registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is `1`.
With XInput2 the events carry the physical slave device's `sourceid` and
`SDL_GetKeyboards()[0]` may be the master. Binding `SDL_GetKeyboards()[0]` therefore gives
you a map that matches nothing at all, and controls that silently do nothing.
That is not hypothetical — the sidescroller in [Chapter 19](19-tutorial-sidescroller.md)
shipped exactly that bug, and its smoke test passed anyway because the test called the
handlers instead of dispatching events.
`0` is the right spelling for "any" because SDL documents `which` as `0` when the source is
unknown or virtual, and joystick ids start at 1 — so no real device is `0` and nothing is
given up by spending it.
There is no way to remove a binding. `akgl_controller_pushmap` appends and that is the
whole API; the only reset is zeroing `akgl_controlmaps[id]` yourself. **Calling

View File

@@ -23,10 +23,20 @@ cmake --build build --parallel --target sidescroller
./build/examples/sidescroller/sidescroller
```
Arrow keys to run, space to jump, and holding space longer jumps higher. Three flags exist
for the smoke test and are useful by hand too: `--assets DIR` points somewhere other than
the compiled-in asset directory, `--frames N` (or `AKGL_SIDESCROLLER_FRAMES`) exits after
that many frames, and `--autoplay` drives the player from a script instead of the keyboard.
### Controls
| Key | Gamepad | Does |
|---|---|---|
| Left arrow | D-pad left | Run left |
| Right arrow | D-pad right | Run right |
| Space | South button (A) | Jump — hold it longer to jump higher |
Close the window to quit; there is no key bound to it.
Three flags exist for the smoke test and are useful by hand too: `--assets DIR` points
somewhere other than the compiled-in asset directory, `--frames N` (or
`AKGL_SIDESCROLLER_FRAMES`) exits after that many frames, and `--autoplay` drives the
player from a script instead of the keyboard.
## The level
@@ -745,22 +755,37 @@ set_tests_properties(example_sidescroller PROPERTIES
```
Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and
requests a jump every forty-five frames by calling the handlers a keyboard would have
called:
taps jump every forty-five frames, and it does it by pushing synthetic events through
`akgl_controller_handle_event` rather than by calling the handlers:
```c excerpt=examples/sidescroller/player.c
if ( frame == 1 ) {
PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
}
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
ss_game.jump_requested = true;
synthetic.type = SDL_EVENT_KEY_DOWN;
synthetic.key.which = SS_AUTOPLAY_KBID;
synthetic.key.key = SDLK_RIGHT;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
FAIL_ZERO_RETURN(
errctx,
AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
AKGL_ERR_BEHAVIOR,
"the right arrow did not reach the player; the control map matched nothing"
);
}
```
That works because a control-map handler takes the actor and an event, and the keyboard
handlers do not read the event beyond requiring one to be there. In four seconds the script
walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
respawns — so the smoke test exercises the collision and the pickup, not just the startup.
**That distinction is the whole value of the smoke test, and the first version got it
wrong.** Calling the handlers directly is easier and it works — but it skips the binding
table, which is the part with something to get wrong. This game shipped with a control map
that matched no keyboard on earth, and a smoke test that called the handlers passed anyway.
A test that cannot fail is not a test, so the script drives the real dispatch and then
asserts the press actually arrived. Break the binding and the run exits non-zero naming the
reason.
`SS_AUTOPLAY_KBID` is deliberately not `0`: the map binds keyboard `0` meaning *any*
keyboard, and driving it from a non-zero device id is what proves that. In four seconds the
script walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
respawns — so the smoke test exercises the input path, the collision and the pickup, not
just the startup.
A run ends with a line naming what happened, which is worth reading when a change moves the
feel: