Files
LixenandClaude Opus 5.5 e9aeed0be0 v0.5.1 console key releases: Linux VT and FreeBSD vt(4), with a guard
* linux console source: releases from the console's own keyboard, typed by its keymap

SetKeyReport with KeySourceConsole, on a Linux virtual console the game runs
on, switches the keyboard to raw keycodes (K_MEDIUMRAW) while releases are
reported. The kernel allows the switch on the caller's controlling tty, so no
root is needed; a console that refuses is not asked again and the protocol is
next. Going back to presses, Fini and EmergencyReset switch it back.

Each keycode is typed as the console types it cooked, after
drivers/tty/vt/keyboard.c, by the console's own tables read at the switch
(KDGKBENT, KDGKBSENT, KDGKBDIACRUC, KDGKBMETA): modifiers and their left and
right groups, Caps and Num Lock with their lights kept in step (KDSKBLED),
sticky and locked modifiers, Unicode entries, dead keys and compose, the
keypad by Num Lock, function strings, Meta as ESC prefix or bit 7, console
switching by Alt+Fn and Alt+arrows to consoles in use, and from kernel 7.1
cursor and CSI keys with their modifiers. Translation starts from the
console's modifiers and locks (TIOCLINUX 6, KDGKBLED). Each key is held by
its keycode; a key typing several keys taps all but its last. Switching to
another console ends every hold Lost. Mouse reports are off while the
keyboard is raw, since the console types them into the same input, and come
back after it.

The guard: before the first switch the package runs the binary again, named
console-guard, holding the console and the read end of a pipe only the game
writes. If the pipe ends while the keyboard is raw (kill -9, the OOM killer, a
Go fatal error), the guard switches the keyboard back, restores the termios
the game found and shows the cursor, without a reset that would clear the
game's last words. A game leading its console's session takes the console
with it as it dies; its guard then leads a session of its own and opens the
console again once the kernel has hung it up, to restore it.

Checked in QEMU on a real VT, kernels 6.8 and 7.2, the game as uid 1000:
the same key strokes read identically raw and cooked (Shift released first,
Ctrl, Alt, AltGr, Caps and Num Lock, the keypad, function keys, modified
cursor keys on 7.2; dead grave and tilde, compose, a euro sign and Cyrillic
from a loaded keymap), Alt+F2 away ended the hold Lost and Alt+F1 came back,
and the keyboard came back to K_UNICODE with ICANON and ECHO after Fini, a
panic with EmergencyReset, kill -9, and kill -9 of a game leading its session
(left raw without the guard's own session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zCVjspRDuEuMrDTumVhp

* freebsd console source: releases from vt(4)'s keyboard, typed by its keymap

On FreeBSD's vt(4) console the keyboard switches to keycodes (K_CODE),
which vt allows any process with the console open, no root needed. Each
keycode types what vt types for it cooked: the keyboard's keymap and
accent map (GIO_KEYMAP, GIO_DEADKEYMAP) run through a port of the
kernel's genkbd_keyaction, kbdmux's left Alt keypad codes, and
vt_processkey's xterm sequences, ESC for Alt and console switching. The
locks go back to the keyboard and its lights with KDSKBSTATE. Scroll Lock
is inert while raw; with the console's history mode on (Scroll Lock lit)
the keyboard stays as it is and the ask reports presses.

Shared with the Linux source now: the restore EmergencyReset runs, the
guard, which opens again a console revoked or hung up with its session's
leader, and what a key asks of the console. A console that can't go raw
just now (Scroll Lock on, its guard gone, output not drained) is tried
again at the next ask rather than never; a refusal at prepare still
holds for good.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zCVjspRDuEuMrDTumVhp

* example-keys: show each key event, for trying key releases

A viewer for the key events a terminal or console reports: F2 asks for
releases from the console or the terminal's protocol, or gives them
back, and the report each ask came to shows above the events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3zCVjspRDuEuMrDTumVhp

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-21 11:16:27 -04:00

486 lines
12 KiB
Go

package terminal
import (
"strconv"
"unicode/utf8"
)
// The Linux console's keymap and its translation of keycodes, after the
// kernel's own (drivers/tty/vt/keyboard.c), so a keyboard switched to raw
// keycodes types what the console types with it cooked. Keysyms are kept as
// the kernel keeps them: 0xf000 | type<<8 | value for a typed one, anything
// less a Unicode code point.
// Keysym types (linux/keyboard.h)
const (
ktLatin = iota // a Latin-1 character
ktFn // a function key's string
ktSpec // a console function: Enter, the locks, console switching
ktPad // a keypad key
ktDead // a dead key
ktCons // a switch to a console
ktCur // a cursor key
ktShift // a modifier
ktMeta // Alt with a character
ktASCII // a digit of a code typed with Alt held
ktLock // a modifier lock
ktLetter // a letter, which Caps Lock shifts
ktSLock // a sticky modifier
ktDead2 // a dead key by its character
ktBrl // a braille dot
ktCSI // a CSI ~ key that says its modifiers (kernel 7.1 and later)
)
// Modifier groups, the bits of the shift state (linux/keyboard.h)
const (
kgShift = iota
kgAltGr
kgCtrl
kgAlt
kgShiftL
kgShiftR
kgCtrlL
kgCtrlR
kgCapsShift
nrShift
)
// The lock flags, as KDGKBLED reports them
const (
ledScroll = 1 << iota
ledNum
ledCaps
)
const (
nrKeys = 256 // keycodes a keymap maps
kNoSuchMap = 0x027f // KDGKBENT's answer for the first key of a map the keymap lacks
symHole = 0xf200 // K_HOLE as the kernel keeps it: a key that does nothing
)
// linuxKeymap is a Linux console keymap as KDGKBENT, KDGKBSENT and
// KDGKBDIACRUC read it
type linuxKeymap struct {
maps [256]*[nrKeys]uint16 // by shift state, nil where the keymap has none
funcs [256]string // the function keys' strings
accents []linuxAccent // what a dead key makes of the character after it
escMeta bool // Meta keys type ESC and the character (K_ESCPREFIX), else it with bit 7 set
csiMods bool // cursor and CSI keys say their modifiers, as from kernel 7.1
}
// linuxAccent is an accented character: a dead key's character and the
// character typed after it
type linuxAccent struct {
diacr, base, result rune
}
// linuxKeys translates keycodes as the Linux console does in Unicode mode,
// keeping its modifiers, locks, lock flags and dead keys. Codes typed with
// Alt and the keypad and braille type nothing; the keypad and cursor keys
// type as they do outside application mode, which the package never sets.
type linuxKeys struct {
km *linuxKeymap
down [nrKeys / 64]uint64 // keys down
shiftDown [nrShift]uint8 // keys down of each modifier
shift uint16 // modifiers down
lock uint16 // modifiers locked
slock uint16 // modifiers stuck for the next key
leds uint8 // lock flags
diacr rune // a dead key's character, for the next character
deadNext bool // compose: the next character is a dead key's
}
// seed starts translation from the console's state: the modifiers it has
// down and its lock flags; keys already down are not known
func (k *linuxKeys) seed(shift, leds uint8) {
*k = linuxKeys{km: k.km, leds: leds & (ledScroll | ledNum | ledCaps)}
for g := range nrShift {
if shift&(1<<g) != 0 {
k.shiftDown[g], k.shift = 1, k.shift|1<<g
}
}
}
// isDown is whether a key is down
func (k *linuxKeys) isDown(code int) bool {
return code < nrKeys && k.down[code/64]&(1<<(code%64)) != 0
}
// key translates one keycode event: down for a press, rep for a repeat. It
// returns what the console types for it and what it asks of the console.
func (k *linuxKeys) key(code int, down, rep bool) (typed []byte, act consoleAct) {
if code >= nrKeys {
return nil, act // past every keymap
}
if down {
k.down[code/64] |= 1 << (code % 64)
} else {
k.down[code/64] &^= 1 << (code % 64)
}
state := ((k.shift | k.slock) ^ k.lock) & 0xff
km := k.km.maps[state]
// A key that says its modifiers falls back to the plain map when the
// modifiers have no map: the kernel compares the entry with the bare
// K_HOLE, which no kept entry is, so a hole in a map stays a hole
if km == nil && state != 0 && k.km.csiMods && k.km.maps[0] != nil {
if t := symType(k.km.maps[0][code]); t == ktCur || t == ktCSI {
km = k.km.maps[0]
}
}
if km == nil {
k.computeShift()
k.slock = 0
return nil, act
}
sym := km[code]
if sym < 0xf000 {
if down {
typed = k.unicode(typed, rune(sym))
}
return typed, act
}
t, val := symType(sym), byte(sym)
if t == ktLetter {
t = ktLatin
// The kernel takes the shifted entry's value, whatever its type
if k.leds&ledCaps != 0 {
if m := k.km.maps[state^1<<kgShift]; m != nil {
val = byte(m[code])
}
}
}
switch t {
case ktLatin:
if down {
typed = k.unicode(typed, rune(val))
}
case ktFn:
if down {
typed = append(typed, k.km.funcs[val]...)
}
case ktSpec:
if down {
typed, act = k.spec(val, rep)
}
case ktPad:
if down {
typed = k.pad(val)
}
case ktDead:
if down && int(val) < len(deadChars) {
typed = k.dead(typed, rune(deadChars[val]))
}
case ktDead2:
if down {
typed = k.dead(typed, rune(val))
}
case ktCons:
if down {
act.vt = int(val) + 1
}
case ktCur:
if down {
typed = k.cur(val)
}
case ktShift:
k.shiftKey(val, down, rep)
case ktMeta:
if down && k.km.escMeta {
typed = append(typed, 0x1b, val)
} else if down {
typed = append(typed, val|0x80)
}
case ktLock:
if down && !rep && val < 16 {
k.lock ^= 1 << val
}
case ktSLock:
k.shiftKey(val, down, rep)
if down && !rep && val < 16 {
k.slock ^= 1 << val
if k.km.maps[(k.lock^k.slock)&0xff] == nil {
k.slock = 1 << val
}
}
case ktCSI:
if down && k.km.csiMods {
typed = k.csi(val)
}
}
if t != ktSLock {
k.slock = 0
}
return typed, act
}
// deadChars are the characters of the dead keys KT_DEAD names by number, as
// the accent table knows them
const deadChars = "`'^~\",_U.*=cki#o!?+-)(:n;$@"
// unicode types ch, or what a dead key before it makes of it; after compose,
// ch is the dead key
func (k *linuxKeys) unicode(typed []byte, ch rune) []byte {
if k.diacr != 0 {
ch, typed = k.combine(typed, ch)
}
if k.deadNext {
k.deadNext, k.diacr = false, ch
return typed
}
return utf8.AppendRune(typed, ch)
}
// dead is a dead key: the next character takes its accent; one already
// pending combines with it first
func (k *linuxKeys) dead(typed []byte, ch rune) []byte {
if k.diacr != 0 {
ch, typed = k.combine(typed, ch)
}
k.diacr = ch
return typed
}
// combine is what the pending dead key makes of ch: their accented character,
// the dead key's own for space or itself, else ch with the dead key's
// character typed before it
func (k *linuxKeys) combine(typed []byte, ch rune) (rune, []byte) {
d := k.diacr
k.diacr = 0
for _, a := range k.km.accents {
if a.diacr == d && a.base == ch {
return a.result, typed
}
}
if ch == ' ' || ch == d {
return d, typed
}
return ch, utf8.AppendRune(typed, d)
}
// symType is a typed keysym's type, -1 for a Unicode one
func symType(sym uint16) int {
if sym < 0xf000 {
return -1
}
return int(sym>>8) - 0xf0
}
// shiftKey moves a modifier, which a second key of the same group holds down
// until both are up; Caps Shift is Shift that ends Caps Lock
func (k *linuxKeys) shiftKey(val byte, down, rep bool) {
if rep || val >= nrShift {
return
}
if val == kgCapsShift {
val = kgShift
if down {
k.leds &^= ledCaps // the kernel ends Caps Lock itself, raw or not
}
}
switch {
case down && k.shiftDown[val] < 255:
k.shiftDown[val]++
case !down && k.shiftDown[val] > 0:
k.shiftDown[val]--
}
if k.shiftDown[val] > 0 {
k.shift |= 1 << val
} else {
k.shift &^= 1 << val
}
}
// computeShift counts the modifiers again from the keys down, by the plain
// map, as the kernel does when a key finds no map
func (k *linuxKeys) computeShift() {
k.shift, k.shiftDown = 0, [nrShift]uint8{}
plain := k.km.maps[0]
if plain == nil {
return
}
for code := range nrKeys {
if !k.isDown(code) {
continue
}
t, val := symType(plain[code]), byte(plain[code])
if t != ktShift && t != ktSLock {
continue
}
if val == kgCapsShift {
val = kgShift
}
if val < nrShift {
k.shiftDown[val]++
k.shift |= 1 << val
}
}
}
// spec runs a console function key (KT_SPEC) as the console does cooked.
// The register and memory dumps, scrollback, Ctrl+Alt+Del, spawning a console
// and the last console do nothing; SAK the kernel runs itself, raw or not.
// Scroll Lock only ever restarts output, so a game can't be frozen.
func (k *linuxKeys) spec(val byte, rep bool) ([]byte, consoleAct) {
var act consoleAct
switch val {
case 0: // K_HOLE
k.computeShift()
case 1: // K_ENTER, after a dead key pending
var typed []byte
if k.diacr != 0 {
typed, k.diacr = utf8.AppendRune(typed, k.diacr), 0
}
return append(typed, '\r'), act
case 5: // K_BREAK, which a raw tty reads as NUL
return []byte{0}, act
case 7: // K_CAPS
if !rep {
k.leds ^= ledCaps
act.leds = true
}
case 8, 19: // K_NUM, K_BARENUMLOCK
if !rep {
k.leds ^= ledNum
act.leds = true
}
case 9: // K_HOLD
act.start = !rep
case 13: // K_CAPSON
if !rep && k.leds&ledCaps == 0 {
k.leds |= ledCaps
act.leds = true
}
case 14: // K_COMPOSE
k.deadNext = true
case 16: // K_DECRCONSOLE
act.vt = vtPrev
case 17: // K_INCRCONSOLE
act.vt = vtNext
}
return nil, act
}
// padChars are what the keypad keys type with Num Lock on (K_P0 to K_PPARENR)
const padChars = "0123456789+-*/\r,.?()"
// The function strings the keypad types with Num Lock off
const (
fnFind = 20
fnInsert = 21
fnRemove = 22
fnSelect = 23
fnPgUp = 24
fnPgDn = 25
)
// pad is a keypad key: its character with Num Lock on, else the navigation
// key it doubles as
func (k *linuxKeys) pad(val byte) []byte {
if k.leds&ledNum == 0 {
switch val {
case 15, 16: // K_PCOMMA, K_PDOT
return []byte(k.km.funcs[fnRemove])
case 0:
return []byte(k.km.funcs[fnInsert])
case 1:
return []byte(k.km.funcs[fnSelect])
case 2:
return k.cur(0)
case 3:
return []byte(k.km.funcs[fnPgDn])
case 4:
return k.cur(1)
case 5:
return []byte("\x1b[G")
case 6:
return k.cur(2)
case 7:
return []byte(k.km.funcs[fnFind])
case 8:
return k.cur(3)
case 9:
return []byte(k.km.funcs[fnPgUp])
}
}
if int(val) < len(padChars) {
return []byte{padChars[val]}
}
return nil
}
// cur is a cursor key (K_DOWN, K_LEFT, K_RIGHT, K_UP): CSI letter, or with
// modifiers down on a kernel that says them, CSI 1 ; modifiers letter
func (k *linuxKeys) cur(val byte) []byte {
if val > 3 {
return nil
}
ch := "BDCA"[val]
if mod := k.csiMod(); mod > 1 && k.km.csiMods {
return []byte{0x1b, '[', '1', ';', '0' + byte(mod), ch}
}
return []byte{0x1b, '[', ch}
}
// csi is a KT_CSI key: CSI value ~, or CSI value ; modifiers ~
func (k *linuxKeys) csi(val byte) []byte {
if val > 99 {
return nil
}
typed := strconv.AppendInt([]byte{0x1b, '['}, int64(val), 10)
if mod := k.csiMod(); mod > 1 {
typed = append(typed, ';', '0'+byte(mod))
}
return append(typed, '~')
}
// csiMod is xterm's modifier parameter for the modifiers down: Shift, Alt and
// Ctrl, the kernel leaving out their left and right groups and AltGr, which
// keymaps use to pick a layout
func (k *linuxKeys) csiMod() int {
mod := 1
if k.shift&(1<<kgShift) != 0 {
mod++
}
if k.shift&(1<<kgAlt) != 0 {
mod += 2
}
if k.shift&(1<<kgCtrl) != 0 {
mod += 4
}
return mod
}
// mediumRaw decodes the Linux console's K_MEDIUMRAW input: a keycode under
// 128 is one byte, bit 7 set on release; a higher one is a byte of 0, 0x80 on
// release, then its high and low 7 bits, each with bit 7 set
type mediumRaw struct {
up bool
high int
n int // bytes of a long keycode seen
}
// decode reports each keycode event in data, keeping a long keycode begun at
// its end for the next call
func (m *mediumRaw) decode(data []byte, key func(code int, down bool)) {
for _, b := range data {
if m.n > 0 && b&0x80 != 0 {
if m.n++; m.n == 2 {
m.high = int(b & 0x7f)
continue
}
m.n = 0
key(m.high<<7|int(b&0x7f), !m.up)
continue
}
m.n = 0 // a long keycode broken off is dropped
if b&0x7f == 0 {
m.up, m.n = b != 0, 1
continue
}
key(int(b&0x7f), b&0x80 == 0)
}
}