JNEXT
Skip to content

3.7 Input

The input subsystem is everything between a host event — a key going down, a gamepad button, a mouse movement — and the byte a Z80 IN instruction eventually reads back. Input state is never pushed at the guest: host events accumulate in a small set of objects, and the guest samples them whenever its own code gets round to reading port 0xFE, 0x1F, 0x37, one of the Kempston mouse ports, or one of the NextREGs that expose raw key and pad state. The only host keys that reach the CPU of their own accord are the emulator function keys handled by EmuFnKeys below, and they do it through reset and NMI rather than through the matrix.

The classes fall on either side of that port boundary, and ownership is what tells them apart rather than the directory they live in. The machine-side classes — Keyboard, Joystick, KempstonMouse, MembraneStick, IoMode, Md6ConnectorX2, EmuFnKeys — are members of Emulator, read by the port handlers Emulator::init() registers. The host-side adapters — GamepadHost, JoystickDispatcher, MouseDispatcher — are owned by the frontend instead (SdlApp, and the Qt MainWindow / QtApp), and exist only to turn SDL events into the state those members hold. Both groups live under src/input/.

A second, more consequential split runs through the same directory. Some classes are transcriptions of FPGA modules and answer to the VHDL. The rest have no VHDL counterpart at all, because they stand in for a physical adapter the FPGA merely assumes is present — the PS/2 driver, the pad cable — or compensate for an artefact of emulating a frame as an indivisible unit. Knowing which group a class is in tells you what its correctness criterion is: the VHDL, or a documented host policy. Nothing else does.

src/input links SDL3 even in the Qt build, because SDL scancodes are the project's canonical host-key type. MainWindow::qt_key_to_sdl() converts Qt keys into them, so both frontends feed Keyboard::set_key() identically and neither can drift from the other.

The keyboard matrix

keyboard.{h,cpp} models the 8 × 5 membrane. matrix_[row] is active-low, row selection comes from the upper byte of the port address (bit N clear selects row N), and read_rows() returns the 5-bit AND of every selected row. Bits 7:5 of the port 0xFE byte are composed by the caller in Emulator, not here.

Three layers apply before that row-select AND, in this order:

  1. Shift hysteresis. membrane.vhd:178 holds the Caps-Shift and Symbol-Shift column state for one extra scan, which delays a release. tick_scan() advances the two-entry history once per membrane scan — 4608 master cycles, which the VHDL annotates as "complete scan every 2.5 scanlines" (zxnext_top_issue2.vhd:1179) — driven from Emulator::on_scanline() by a cycle accumulator. It used to run once per video frame, which stretched a 165 µs hold to 20 ms and left a released Symbol Shift contaminating the next frame's keystroke: a tap of Symbol Shift followed by a tap of P typed " instead of PRINT. The rate is part of the spec, not an implementation detail.
  2. The extended-key fold, described below.
  3. The MembraneStick fold, also below, matching keyb_col <= keyb_col_i_q AND membrane_stick_col AND ps2_kbd_col.

Which compounds the hardware synthesises

Several ZX keys are compounds — CAPS LOCK is Caps Shift plus 2, and so on — and the question of who presses the shift half has been got wrong here once, so it is worth stating precisely.

The sixteen extended keys are folded by the hardware itself. Cursors, DELETE, EXTEND MODE, CAPS LOCK, GRAPH, TRUE VIDEO, INV VIDEO, BREAK, EDIT and the four symbol-shifted punctuation keys (; " , .) each set a bit in a raw 16-bit register, and membrane.vhd:236-240 ANDs that register into the matrix — both halves of the compound. The digit or letter column and the Caps-Shift or Symbol-Shift that goes with it are produced by the membrane, not by whoever is pressing keys.

jnext models that path rather than short-cutting it. set_key() routes these host keys to set_extended_key(id), which touches only ex_matrix_, and read_rows() does the fold, deriving the shift half from the same per-index split the VHDL uses: every index drives Caps Shift except the four punctuation ones, and EXTEND drives both, which is why it comes out as CS+SS. Going the long way round buys two behaviours a directly asserted compound cannot express. NR 0xB0 / 0xB1 read the raw register, so a program can see which extended key is held; and NR 0x68 bit 4 cancels the fold while leaving that register reporting normally. Asserting the compound directly — which jnext used to do — makes both impossible.

Three compounds are synthesised host-side instead, in s_compound[]: / → SS+V, - → SS+J, = → SS+L. On hardware these come from the PS/2 keymap's SYMBOL bit (keymaps.vhd, ps2_keyb.vhd:197), and jnext does not model the PS/2 protocol at all — SDL key events replace it. Both matrix bits are therefore asserted directly, with no raw register behind them and nothing to read back.

When the guest sees a key change

Hardware has no answer to this: the membrane is scanned continuously (membrane.vhd:99-155, a free-running row scanner), so a real key change is asynchronous to the frame. Emulating a frame as an indivisible unit forces a choice, and it is a host policy with an emulator as its oracle, not a VHDL transcription.

The policy: a keyboard change made between frames N and N+1 becomes visible to the guest at the ULA interrupt position of frame N+1 (the extended keys and NR 0xB0/0xB1 with it), and to no read before it. The position itself is the VHDL one, (c_int_h, c_int_v) of zxula_timing.vhd:551 with the per-timing constants: Pentagon (439,319) (zxula_timing.vhd:155 and zxula_timing.vhd:163), 128K (128,1) (zxula_timing.vhd:187 and zxula_timing.vhd:199), +3 and the Next's default timing (126,1) (zxula_timing.vhd:189 and zxula_timing.vhd:199), 48K (116,0) (zxula_timing.vhd:257 and zxula_timing.vhd:265).

The oracle for the order is FUSE 1.5.7. Its frame-end event handler, spectrum_frame_event_fn(), runs (the excerpt is its lines 91 to 103 in FUSE's spectrum.c)

  z80_interrupt();
  ui_joystick_poll();
  timer_estimate_speed();
  debugger_add_time_events();
  ui_event();

in that order: the interrupt is taken first and host input (ui_event()) lands after it. A program polling the keys therefore cannot act on a key before its own interrupt routine has run, and a program whose key handler changes machine state with interrupts enabled (paging a bank that holds music, say) cannot have the interrupt land in the middle of that response because the key arrived a few hundred T-states before it. jnext used to apply every key at its frame boundary, 292 T before the 128K interrupt; a 128K game that pages a second bank in for ~150-300 T on its first sight of a key then read its IM 2 vector from the wrong bank and jumped to $0000 at the start of play (msnampac, found 2026-11). The race is real on hardware, where a key lands at a random phase and the window is hit rarely; jnext made it certain.

How it is built (Keyboard, Emulator::begin_new_frame()):

  • Writers (set_key, set_matrix_bit, set_extended_key, the auto-type tick, the queued levels, release_all_keys) change matrix_ / ex_matrix_ at once, as before. Every producer is covered: host keys, --delayed-keypress, the phantom typist, the tape LOAD "" keys, and the debugger's levels and pulses.
  • The guest reads a separate copy while a frame runs. latch_guest_view() copies the state at the frame edge, first thing, before the edge's own writers (the debugger drain and tick_auto_type()), so the copy is what the frame that just ended showed the guest. hold_guest_view() is called by begin_new_frame(). A release event on scheduler_ at the interrupt cycle (the same expression as the interrupt request, plus one pixel) calls release_guest_view(). Held, read_rows(), tick_scan() (so the Caps/Symbol Shift history latches what the guest saw) and the NR 0xB0/0xB1 readbacks use the copy.
  • Which side of the instruction the commit lands on. The interrupt request and the release both sit in irq_scheduler_, due at the same cycle, and are applied at the end of the instruction the edge falls in. The next instruction is the ISR's first when the interrupt is accepted there, and it reads the new state, which is FUSE's order (the interrupt entered, then the input). An IN that begins before the edge has already read the old one. irq_scheduler_ is also drained mid-instruction by the reads that call sync_io_devices_to(); the keyboard reads (port 0xFE, NR 0xB0/0xB1) never do, so a read cannot tell the two drain points apart. The release is deliberately not on scheduler_, the video-row queue the Copper walk splits instruction windows at. With interrupts disabled (or the ULA interrupt off, NR 0x22 bit 2) the release still fires at the same position: the commit is a raster position, not the interrupt.
  • Nothing is held outside run_frame(). No guest instruction runs between frames, so the test or debugger panel that reads between them sees the live state: matrix_row() and live_extended_keys() (what input_state() reports) are always the raw write side.
  • The copy and its held flag are machine state (the release event is not: begin_new_frame() schedules it again for every frame, including a replayed one): describe_state carries guest_view_held, guest_ex_matrix and guest_matrix_0..7, each with a declared default equal to the reset() value, so a .jns written before they existed loads as "nothing held", and a rewind replay of a frame whose boundary snapshot has a pending key shows it at the interrupt, as the original run did.
  • Joystick and mouse input are not covered: they still change at the frame boundary (decision, 2026-11-05; no program in hand needs it).

Rows HIC-01 to HIC-11 of input_integration_test pin the policy; input-int-commit-func runs it end to end on an injected program that reproduces the paging mechanism.

Joysticks

joystick.{h,cpp} is the machine side. It decodes NR 0x05 into a per-connector mode (Sinclair 1/2, Cursor, Kempston 1/2, MD3 left/right, I/O mode) and composes the port 0x1F and 0x37 reads. Each connector holds a 12-bit raw vector whose layout is zxnext.vhd:3441-3442.

The constraint that shapes everything above it is that only six of those twelve bits ever reach a port, and two of them conditionally. U/D/L/R and C/B are readable in Kempston or MD mode; START and A are driven in MD mode only and hard-tied to 0 in Kempston; MODE/X/Z/Y reach no port under any mode and are visible only through NR 0xB2. So a host pad's four face buttons are mapped onto B, C, A and START — the bits a program can actually read — and the MD6 latch bits are bound only to spare controls.

membrane_stick.{h,cpp} (membrane_stick.vhd) is the joystick-to-keyboard adapter used by the Sinclair and Cursor modes. It is a module of its own, upstream of the port 0xFE read rather than something inside Keyboard, exactly as the VHDL decomposes it. Its user-defined keymap is a 64 × 6-bit RAM reprogrammable through NR 0x28 / 0x29 / 0x2B, initialised from the FPGA's own keyjoy_64_6.coe.

md6_connector_x2.{h,cpp} is a faithful transcription of the MD6 scan FSM, and it is not on any live read path: the NR 0xB2 handler reads Joystick::nr_b2_byte() instead. Nothing feeds it, because detecting a six-button pad needs a physical select-pulse handshake that a host gamepad cannot produce. It is a reference model with its own test rows, and fixing a bug in it changes no emulator behaviour.

iomode.{h,cpp} implements the NR 0x0B pin-7 multiplexer, which can be static, CTC-toggled, or driven by a UART TX line.

Mouse

mouse.{h,cpp} is a Kempston mouse: 8-bit X and Y counters read raw at ports 0xFBDF / 0xFFDF, with active-low buttons and a 4-bit wheel at 0xFADF. The ports decode on twelve address lines only — A15:A12 are don't-care — so IN A,(0x2ADF) reaches the mouse exactly as it would on hardware.

Two details sit deliberately outside this class. The NR 0x83 bit 5 gate is enforced one layer up, in the emulator's port handler. And NR 0x0A button-reverse and DPI are stored but not applied, because neither has an in-core VHDL consumer — both are concerns of the host adapter rather than of the machine.

The host-side layer

Nothing below has a VHDL oracle. Each is judged instead by the shape it lands in the machine-side class, plus a stated policy.

  • joystick_dispatcher / gamepad_host / mouse_dispatcher stand in for the physical adapters the FPGA expects to be driving its input pins. GamepadHost owns the SDL controller lifecycle; the two dispatchers translate SDL button, axis, motion and wheel events into the machine-side vectors and counters. Both frontends use the same classes, so behaviour cannot drift between them.

  • Which controller drives which connector is policy in joy_assign.{h,cpp} (pure, no SDL), not an accident of arrival order. GamepadHost keeps the list of present devices and, whenever a device arrives or leaves, a connector's source changes or an assignment is made, asks resolve_joy_assignment() and opens/closes to match. Per connector: R1 a source other than Sdl (including JoySource::None) gets no device and a dormant assignment reserves nothing; R2 an assigned id that is present wins and pre-empts whoever holds it; R3 otherwise a connector keeps its current device (sticky: a pad is never moved under the player); R4 otherwise it takes the first untaken device in arrival order, Joy 1 then Joy 2. An assigned id that is absent falls through to R3/R4, which is the fallback, logged once on entry. The identity is the SDL GUID plus an ordinal (<guid> or <guid>#N, joy_source.h): N is the smallest ordinal no present device of that GUID holds, so unplugging pad #1 leaves pad #2 as #2. The ids live in a process-wide iid -> id registry (GamepadHost::id_registry_), because a cold boot rebuilds the host while SDL and its instance ids carry on: without it, identical pads would be renumbered in arrival order and an assignment would move to the other physical pad. Entries last until SDL stops listing the device (REMOVED, or the purge in the next enumerate/arrival); only a full restart renumbers. The Emulator stores the assignment next to joy_source_ (strings only, set_joystick_device(), one controller per connector, last pick wins), the cold boot carries it, and the Input menu and Preferences build the same choice list from joy_choices(). Whenever a device leaves a connector (close_slot()) the dispatcher's release_connector() clears everything it held, since nothing will send the release.

  • JNEXT_TEST_VIRTUAL_JOYSTICKS (GamepadHost::attach_test_devices_from_env(), called by both frontends right after SDL_Init) attaches SDL virtual joysticks described as name:vvvv:pppp[:raw];... (vendor/product in hex; raw has no gamepad mapping) and admits only virtual devices from then on, so the regression row joystick-picker-func can drive the real binaries with no hardware and a physical pad on the host cannot disturb it. Inert when unset. The unit suites get the same isolation from GamepadHost::set_virtual_only(), because SDL3 has no hint that disables its Linux backends.

  • pointer_capture.h (in src/platform/) decides what the Qt frontend does with each motion event while the pointer is captured — forward the delta, re-centre, or ignore the echo of its own warp. SDL does not use it, because its relative mode does the equivalent natively. Both frontends do use its release_chord() for the release hint: Qt on macOS reads Command as Ctrl, so the chord there is Alt+Cmd. The release gesture itself is configurable: ReleaseFilter is the toolkit-free decision for one of three exclusive methods (ctrl-alt, middle-click, esc), returning per event whether to release and whether to consume it, and release_hint() is the one spelling of the gesture for every title, tip, log line and Preferences label. Qt calls the filter from keyPressEvent, keyReleaseEvent and mousePressEvent; SDL calls it through the templates of sdl_pointer_release.h, so a suite can drive them through the real SdlInput::poll(). Ctrl+Alt is deliberately not consumed (swallowing Alt would desync Keyboard::alt_held_); Esc is consumed whole, including SDL repeats and the release, and a focus loss forgets a pending swallow. Its default policy measures every delta from the centre, which needs a warp that lands at once: X11 and Windows. macOS and Wayland get a second policy, DeferredWarpPolicy. On macOS QCursor::setPos is a synthetic event that lands late, or never without the Accessibility permission, so the frontend warps with CGWarpMouseCursorPosition instead (src/gui/mac_cursor_warp.cpp). On Wayland the warp is a compositor request that may land late or be refused (wp_pointer_warp_v1, Qt 6.10 and later), or does nothing at all (Qt 6.9 and earlier); it stays QCursor::setPos, and deferred_warp_platform() picks the policy at capture time from the Qt platform name, so X11 (xcb), Windows and offscreen are unchanged. The deferred policy measures each delta from the last position seen rather than from the centre, and re-centres only once the pointer has drifted a quarter of the viewport away.

    Two limits are deliberate and pinned by tests. A single event moving more than an eighth of the viewport straight at the centre while a warp is in flight can be mistaken for the warp landing; and a warp that lands only after the pointer has itself come back near the centre is forwarded as a small jump. On Wayland without a working warp the motion stays exact but the pointer is not confined: plain Wayland cannot hold it without the pointer constraints protocol, so it can leave the window, and guest motion stops until it comes back.

  • host_key_latch.h (also src/platform/) holds two policies, and both exist because of the frame boundary. Host key events are delivered between run_frame() calls, so the matrix is sampled once per emulated frame.

    Latch is the minimum-hold half: a press plus its release arriving in the same gap would be invisible to the guest, so the latch defers such a release until a frame has actually run.

    Router is the serialiser, and it is the half that decides when a press may land. Holding a press was never enough on its own: the next key pressed while the first one's release was still deferred used to be asserted on top of it, so the frame sampled a two-key chord the host never had. KEY-SCAN rejects a two-key chord, so both characters vanished — and a tap of Symbol Shift followed by a tap of P decoded as Symbol-Shift + P, producing the wrong character rather than none. The router therefore queues host events and applies a press only when the queue is empty, no release is deferred, and no non-modifier press is still waiting for a frame. The SDL modifier block 224..231 — the two ZX shifts and host Alt — never arms that last condition, which is what keeps a genuine shift-plus-key chord in a single frame; what separates "shift held as a modifier" from "shift tapped on its own" is the host's own key-up event and nothing else. Autorepeat presses are dropped rather than queued, because SDL forwards them and Qt does not.

    Real hardware has neither problem — it scans at membrane rate and merges nothing — so neither policy is a fidelity claim about the Next; both are compensation for jnext delivering host input once per frame.

    Nothing is lost holds for the matrix, and it is not enough for the guest. 48.rom's KEYBOARD routine keeps two key sets (KSTATE at $5C00 and $5C04): every interrupt decrements each busy set's counter and frees it at 0 (0x02C6-0x02D6), a new key needs a free set (with none, RET Z at 0x02F0 throws it away), a new key's counter is set to 5 (LD (HL),$05 at 0x02F4), and the same key seen again while its set is busy is a repeat (K-REPEAT, 0x0310), not a keystroke. The same bytes are in the 128, +3 and Next ROMs. A backlog replayed one keystroke per frame therefore lost the third distinct key and every repeated one inside the ROM, even though each had been shown to a frame. The router now spaces what it schedules: TAP_SPACING_FRAMES (5) after a stretched tap (a press whose release arrived before any frame saw it, so jnext, not the host, picks the length and the next key's time), applied to queued presses only. A press that arrives on an idle router keeps the host's own timing, so games and ordinary typing pay nothing; the price is that a backlog drains at 10 keys/s at 50 FPS. Two taps of the same key in one gap are now two keystrokes. The spacing counts frames (on_tick_end(frames)), never ticks, and attach() and release_all() forget it.

    Host actions are barriers in the same queue. A Quick Save or Quick Load is requested through Router::on_host_action(fn): it runs once every keystroke typed before it has been shown to a frame and released, and every host event typed after it queues until the frontend calls action_done(). fn only raises the request (a flag; QtApp::pending_quick_, SdlApp::quick_*_requested_), and the tick services it, so nothing saves or cold-boots from inside the router. The user's order is kept on both sides: the save no longer overtakes the keys before it, and keys typed behind a Quick Load survive its cold boot, because attach() keeps the queue when a barrier is up (everything in it was typed after the action, so it belongs to the new machine). release_all() drops the queued keys but keeps queued actions, so a focus loss does not cancel a save the user asked for. After a snapshot load wipes the matrix, Router::reassert() puts back the keys the router holds (the four windowed-frontend load sites all go through emulator_apply_load_keeping_keys() in emulator_boot.h, so none can forget it), so the guest matrix is exactly what the host holds through the router. Keys typed while a Quick Save/Load is in progress are delivered afterwards, in order.

    Router::release_all() is the third policy, and it answers a different question: what if the key-up never arrives at all? A Qt popup and a focus change both take the keyboard away mid-keystroke, and the key-up then goes to whatever took it. Opening the File menu with Alt+F swallows the F key-up and the Alt key-up, which leaves host Alt asserted in Keyboard for the rest of the session — after which E, G and C resolve to EDIT, GRAPH and CAPS LOCK and those three letters silently stop typing while every other letter still works. With an ordinary letter stranded instead, the ROM's own auto-repeat fills the BASIC line. MainWindow::focusOutEvent therefore reports the loss, the frontend releases everything the sink holds, and the invariant is that jnext never holds a guest key down whose release it cannot observe. The Router tracks what the sink holds separately from what the host holds for this: releasing a key that was never pressed would clear matrix bits a compound key shares — /, - and = each write two bits directly and all three share the Symbol Shift cell. The price is stated rather than hidden — a press not yet shown to a frame is dropped instead of held, which is correct, because the user has moved to a menu. The Router is also where the script host keys live: Alt+1..Alt+8 with no other modifier held call the callback wire_script_keys() (host_key_wiring.h) installs — the backend's Host{scriptN} — and the digit's press and release are swallowed, script loaded or not. Alt+9/Alt+0 and every other key are unchanged. One place for both the Qt and the SDL window, so they cannot disagree; the debugger window has its own eight QActions on the same chords, and the debugger keymap refuses them.

  • phantom_typist.{h,cpp} types LOAD "" for you. Loading a tape on a real Spectrum starts with the user typing it and pressing ENTER — or, on a 128K or +3, accepting the boot menu's default entry — and jnext does that itself when a .tap is loaded, so a cold machine goes from reset to loading with no interaction. The hard part is when: BASIC will not accept a keystroke for a good fraction of a second after reset, and how long depends on the machine. This class is a port of FUSE's answer. It watches port 0xFE reads, accumulating which of the eight half-rows have been selected, and treats a full sweep of all eight within one frame as proof that the ROM's own scan loop is running. It then waits eight more frames — the first full sweep happens while the copyright message is still printing, before the BASIC editor is reading — and only then queues the per-machine sequence: J, Symbol-Shift + P twice and ENTER on a 48K, a bare ENTER on the 128K and +3 where the default menu entry loads a tape itself. It replaced a fixed frame delay that was fragile across the three boots. See 3.8 Media and loaders for which formats arm it.

  • emu_fnkeys.{h,cpp} is the odd one out: it is a transcription (emu_fnkeys.vhd), but nothing drives it from a real membrane. The frontends call simulate_mf_fkey_press() for F3/F5/F6/F7/F8, and Emulator::on_hotkey_*() for F1/F4/F9/F10 — the four that act on the machine itself rather than on the matrix: hard reset, soft reset, Multiface NMI and DivMMC NMI.

Alt is the host namespace, Ctrl is the guest's

Every plain Ctrl+key is a real ZX sequence, because Ctrl is Symbol Shift. jnext therefore puts its host shortcuts on Alt, and gives up binding the Alt keys as guest shift keys to pay for it. That is a deliberate divergence — real hardware maps Left Alt to EXTEND MODE and Right Alt to GRAPH (keymaps.vhd:85-94) — and jnext follows the FUSE / ZEsarUX convention instead, with EXTEND on Tab and BREAK on Esc. Keyboard treats Alt as a modifier that is never a ZX key, and latches at press time which table a scancode resolved through, because the user may well release Alt before the key.

One consequence matters to anyone touching the GUI. Alt+letter is a single namespace shared by the Qt QAction shortcuts and the menubar mnemonics, and Alt+E, Alt+G, Alt+C and Alt+` are reserved for the guest's EDIT, GRAPH, CAPS LOCK and INV VIDEO. Adding a menu on one of those would silently kill a guest key, so host_hotkey_test pins the two sets disjoint — see Testing.

macOS: Command is the host modifier

On macOS the same split sits one key over. Qt reports Command as Key_Control / ControlModifier / Qt::CTRL and the physical Control key as Key_Meta (jnext does not set AA_MacDontSwapCtrlAndMeta), so gui/host_shortcuts.h holds the host set for both platforms, with the platform as a parameter so the macOS branch is unit-tested on Linux (host_shortcuts_test): sequence() (Command+letter, Command+Q, Command+,; no Alt+F4), script_sequence(), f1_decision() / f4_decision() (Command for Alt, Option inert, physical Control as Ctrl), and modifier_scancode(), which maps Command to the Router as LGUI (the guest ignores it) and the physical Control key to Symbol Shift. CommandChordGate keeps an unbound Command chord from reaching the guest. The key Router's script chord (set_script_chord_gui()) and the SDL frontend's quick-snapshot / F1 / F4 decisions (platform/host_modifier.h, QuickChordTracker::set_host_is_gui()) take the host modifier as a parameter whose default is Alt, so every Alt row keeps its meaning on a Mac build. The debugger keymap's reserved chords follow the same flag (validate_combo(c, why, host_mod_is_ctrl)). Add a host shortcut to the host_shortcuts::Action table, never as a literal Qt::ALT (HS403-27 fails on one).

The one bare non-function host key is Pause, an alternate shortcut on Machine > Pause (an owner-approved exception to the Alt rule; it is the conventional emulator pause key). It is safe because Next PS/2 Pause/Break is "reset ps2 module" (keymaps.vhd:104, ps2_keyb.vhd:245) with no guest meaning, and jnext models no PS/2. Alt+U stays the primary shortcut (QMenu shows only the primary, and Mac keyboards have no Pause key). The action does not auto-repeat.

Qt has its own claim on Alt, and jnext declines it. A bare Alt tap normally moves keyboard focus to the menu bar, where the next letter that is a top-level mnemonic is swallowed and opens that popup instead of typing. In an ordinary application that is a convenience; here Alt is part of typing, so the menu bar is given a QProxyStyle reporting SH_MenuBar_AltKeyNavigation as off (src/qt/menu_bar_alt_nav_qt.h, installed by both the emulator window and the debugger window). Alt+letter mnemonics are unaffected — Qt opens those through the shortcut map, not through this hint — and handle_key() additionally feeds the guest nothing at all while a Qt popup owns the keyboard.

This is a settled decision, taken 2026-09-25, not a provisional workaround. The reasoning: Alt is simultaneously a guest modifier (EDIT / GRAPH / CAPS LOCK) and this project's entire host-hotkey namespace, so pressing it is an ordinary part of typing here in a way it is not in other applications; every menu remains reachable by Alt+letter and navigable with the arrow keys once open; and the only thing given up is the tap-Alt-then-arrow convenience, which in this application was silently eating keystrokes rather than helping. For an emulator with an overloaded Alt it is a net accessibility improvement, and the user guide says so to anyone who used the old route.