feat(input): action maps + deterministic record/replay (#7)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m19s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 20s

The two ideas the input revamp leads with, built in Ludic over the
single-key poll every target already provides:

- Action maps: gameplay reads named actions, not physical keys, so keys
  are rebindable and a scheme is data. Input.bind(action, key),
  Input.down/pressed(action), Input.rebind(action, from, to).
- Deterministic record/replay: Input.poll() is the one per-frame input
  read; Input.record() captures the key each frame and Input.replay()
  feeds the tape back, so a run reproduces exactly — the seed of lockstep
  netcode. "Read input" and "read a recorded snapshot" are the same call.

runtime/native/input.ludic (spliced when the new Input.* methods are used;
pulls in core.ludic for rt_poll). emit_ns_call routes the methods to the
@fn_input_* runtime; parse.ludic gates the splice. Seven docs/language
pages; worked example + regression examples/library/input_actions.ludic
(1 0 1 1 0 1 0). Full suite 75 passed, self-host fixpoint intact, no golden
drift. The device layer (multi-key held, gamepads, touch, analog) needs a
platform key-state backend and is tracked separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 16:03:56 +03:00
parent 3679ce1797
commit 377b6d1186
15 changed files with 19159 additions and 18515 deletions

155
runtime/native/input.ludic Normal file
View file

@ -0,0 +1,155 @@
# ============================================================================
# input.ludic — action maps + deterministic input recording/replay (#7).
#
# The raw platform gives one key per frame (Input.key / rt_poll). This layer
# adds the two ideas the input proposal leads with:
#
# * Action maps — gameplay reads *named actions*, not physical keys, so a key
# is rebindable at runtime and a scheme is data. Bind with Input.bind, read
# with Input.down / Input.pressed, remap with Input.rebind.
# * Deterministic record/replay — because the sim is deterministic in its input
# stream, snapshotting the per-frame key and feeding it back reproduces a run
# exactly (free replays, the seed of lockstep netcode). Input.poll is the one
# call that advances a frame of input; it reads the live key, records it, or
# replays a recorded one depending on the mode — "read input" and "read a
# recorded snapshot" are the same call, as the proposal asks.
#
# All integer and deterministic. The device layer the proposal also sketches —
# multiple simultaneous keys, gamepads, touch, analog axes/vectors — needs a
# platform key-state backend and is tracked separately; this layer stands on the
# single-key poll every target already provides.
# ============================================================================
const INPUT_MAX_ACT: int = 32 # named actions
const INPUT_MAX_KEYS: int = 4 # physical keys bound per action
const INPUT_REC_CAP: int = 8192 # recordable frames
var input_names: pointers = null # action name per slot (0..input_nact)
var input_keys: words = null # INPUT_MAX_ACT * INPUT_MAX_KEYS key codes (0 = empty)
var input_nact: int = 0
var input_frame: int = 0 # the key polled this frame
var input_last: int = 0 # the key polled last frame (for edges)
var input_mode: int = 0 # 0 = live, 1 = record, 2 = replay
var input_rec: words = null # recorded key per frame
var input_recn: int = 0 # frames recorded
var input_pos: int = 0 # replay / record cursor
function input_init() -> void {
if input_names == null {
input_names = bytes(INPUT_MAX_ACT * 8) # a pointer (8 bytes) per action slot
input_keys = words(INPUT_MAX_ACT * INPUT_MAX_KEYS)
}
}
# slot of the action `name`, or -1. Names compare by byte-string equality.
function input_find(name: pointer) -> int {
input_init()
var i = 0
while i < input_nact {
if input_names[i] == name { return i }
i = i + 1
}
return 0 - 1
}
# get-or-create the slot for `name`.
function input_slot(name: pointer) -> int {
let f = input_find(name)
if f >= 0 { return f }
if input_nact >= INPUT_MAX_ACT { return INPUT_MAX_ACT - 1 } # silently reuse the last slot when full
let s = input_nact
input_names[s] = name
input_nact = input_nact + 1
return s
}
# bind physical `key` to the named action, creating the action if new. A key
# already bound to the action is left as-is (idempotent).
function input_bind(name: pointer, key: int) -> void {
let s = input_slot(name)
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == key { return } # already bound
i = i + 1
}
i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == 0 { input_keys[base + i] = key; return }
i = i + 1
}
}
# runtime rebinding: replace `oldkey` with `newkey` on the named action. A no-op
# if the action or the old key is not found.
function input_rebind(name: pointer, oldkey: int, newkey: int) -> void {
let s = input_find(name)
if s < 0 { return }
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == oldkey { input_keys[base + i] = newkey; return }
i = i + 1
}
}
# does key `k` (0 = none) fire the action in slot `s`?
function input_slot_has(s: int, k: int) -> bool {
if s < 0 { return false }
if k == 0 { return false }
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == k { return true }
i = i + 1
}
return false
}
# Advance one frame of input and return the frame's key. This is the single
# per-frame input read: call it once at the top of a frame.
# live — read the live key (rt_poll).
# record — read the live key and append it to the recording.
# replay — take the next key from the recording (the live device is ignored).
function input_poll() -> int {
input_last = input_frame
if input_mode == 2 { # replay
if input_pos < input_recn { input_frame = input_rec[input_pos]; input_pos = input_pos + 1 }
else { input_frame = 0 } # past the end of the tape: no input
return input_frame
}
let k = rt_poll()
if input_mode == 1 { # record
if input_rec == null { input_rec = words(INPUT_REC_CAP) }
if input_recn < INPUT_REC_CAP { input_rec[input_recn] = k; input_recn = input_recn + 1 }
}
input_frame = k
return k
}
# is the named action held on the frame last polled?
function input_down(name: pointer) -> bool {
return input_slot_has(input_find(name), input_frame)
}
# did the named action go down this frame (down now, not down last frame)?
function input_pressed(name: pointer) -> bool {
let s = input_find(name)
return input_slot_has(s, input_frame) and (not input_slot_has(s, input_last))
}
# begin recording polled input from the next frame (resets the tape).
function input_record() -> void {
if input_rec == null { input_rec = words(INPUT_REC_CAP) }
input_recn = 0
input_pos = 0
input_mode = 1
}
# replay the recording from its start; subsequent Input.poll calls read the tape.
function input_replay() -> void {
input_pos = 0
input_mode = 2
}