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

@@ -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: