Files
StarForth/src/starkernel/repl.c
T
Robert Allan JamesandClaude Sonnet 5 4d4ab59189 Build the FIRSTTOUCH overflow trigger flagged in FABRIC-2.md §I.2
The overflow-triggered migration path (a WIREBIND-attached identity's own
drive running low on space) was scoped but never built -- only the
trigger-detection call site was missing, per this section's own text.

- capsule_wirebind.c now tracks the attached blkio_dev* alongside the
  already-tracked VM id, set in try_attach() and cleared in both
  EJECT/UNCLEAN paths.
- New capsule_wirebind_overflow_idle_check(), called once per idle tick
  in repl.c right alongside blk_migration_idle_check() (same cadence):
  reads the attached drive's free/total via blk_get_device_free_blocks(),
  and if free space is below a fixed 10% threshold, extends the
  identity's pool with a one-time blk_firsttouch_claim() of 8 additional
  devblocks on Artemis's system-resident device.
- New blk_owner_has_claim(owner_fp) in block_subsystem.c answers the
  debounce question blk_firsttouch_claim()'s own doc comment had left
  open: a disk scan, not a RAM flag, so the already-extended answer
  survives reboot/reattach, matching BMAPFMT's "ownership travels with
  the block" model.

Premise checked before building (does a WIREBIND-attached drive actually
give a real free/total signal, or does it stay PROVISIONAL/raw): traced
repl.c's attach sequence and confirmed blk_subsys_attach_device() runs on
the same dev pointer right after WIREBIND, and a WIREBIND-eligible drive
is always already STFR/v2-formatted, so the signal is real. Premise held,
unlike the BAM item's overstated one.

Verified with the mandatory 3-arch QEMU acceptance (identical dictionary
hashes, no regression) plus a live logic test of blk_owner_has_claim():
a temporary TEST-OWNER-CLAIM word, run once via SK_CMD and reverted,
confirmed it correctly detects the claiming owner and rejects an
unrelated one. The low-disk-space-triggers-a-claim path itself is not
verified end-to-end -- that needs a real minted WIREBIND-user thumbdrive
with deliberately tiny capacity, out of scope for this pass; noted as
such in the FABRIC-2.md §I.2 closure note rather than overclaimed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EjXFo7mPXjUMjfJeuUUz4
2026-09-05 16:17:19 -04:00

984 lines
45 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
StarForth — Steady-State Virtual Machine Runtime
Copyright (c) 2023–2025 Robert A. James
All rights reserved.
Licensed under the StarForth License, Version 1.0
*/
/**
* repl.c - Emergency FORTH REPL for LithosAnanke kernel
*
* Direct adaptation of src/repl.c for the freestanding kernel context.
* Replaces libc stdio (fgets/printf/fflush) with HAL serial I/O:
* - Input: console_getc() non-blocking poll with local echo and backspace
* - Output: console_puts() / console_putc()
*
* Idle spin: polls console_getc() and services the adaptive heartbeat.
* The timer ISR's top half (heartbeat_tick()) latches one
* sample per interrupt; the idle spin drains it every
* iteration via heartbeat_service() (item 0.8, FABRIC-0.md §26)
* and calls sk_repl_idle() once per SK_IDLE_BEAT_INTERVAL ticks
* for coarser subsystem dispatch. On QEMU TCG the ISR must fire
* for ticks to advance — check "Heartbeat: N ticks" in the
* serial log to confirm.
*
* Runs with interrupts enabled so the APIC heartbeat fires normally.
* Designed as the last thing kernel_main does before the idle loop.
*/
#include "starkernel/repl.h"
#include "console.h"
#include "log.h"
#include "vm.h"
#include "version.h"
#include "starkernel/timer.h"
#include "starkernel/arch.h"
#include "starkernel/xhci_driver.h"
#include "starkernel/blkio_usb.h"
#include "starkernel/homeblocks_sig.h"
#include "starkernel/capsule_birth.h"
#include "starkernel/capsule_zuse_boot.h"
#include "starkernel/capsule_wirebind.h"
#include "starkernel/capsule_run.h"
#include "starkernel/vm/bootstrap/sk_vm_bootstrap.h"
#include "block_subsystem.h"
#include "word_source/include/keyboard_words.h"
#include "word_source/include/block_words.h"
#include "freestanding/stdio.h"
#include <stdint.h>
#include <string.h>
/* FABRIC-0.md 4.4: "ok>" (including its trailing space) renders in bright
* cyan, 0x55FFFF -- reuses FB_ANSI_PALETTE[14]. Sent as a real SGR escape
* so it colors both the framebuffer (parsed by vt100.c's apply_sgr()) and
* any ANSI-aware serial terminal, per 4.4c's "identical on both" goal. */
#define SK_PROMPT_TEXT "\x1b[38;2;85;255;255mok> \x1b[39m"
const char lithos_version[64] = LITHOS_VERSION_STR;
/* FABRIC-0.md §27.8/4.4s, unblocked 2026-09-04: extends the prompt to
* "[VM name] (user) ok>" (e.g. "[Hera] (zuse) ok>") whenever an
* identity is currently attached -- Zuse (mama_vm->zuse_session; there
* is only ever one, so no username lookup needed) or a regular WIREBIND
* user (capsule_wirebind_attached_username()). Checked independently of
* which VM's own bracket console.c is currently showing: both identities
* are console-level attach state, not per-VM dictionary state, so the
* segment reflects "who is at the console" the same way regardless of
* which VM you've USE'd into. Prints nothing (bare "ok> ", today's
* existing format, unchanged) when neither is attached -- Hera's own
* documented normal steady state (FABRIC-2.md §D.2). */
static void sk_print_prompt(void) {
VM *mama_vm = (VM *)sk_get_mama_vm();
const char *username;
if (mama_vm && mama_vm->zuse_session) {
console_puts("(zuse) ");
} else if ((username = capsule_wirebind_attached_username()) != (const char *)0) {
console_puts("(");
console_puts(username);
console_puts(") ");
}
console_puts(SK_PROMPT_TEXT);
}
/*===========================================================================
* USE-word dispatch: which VM receives REPL input.
*
* NULL means "use the REPL's own vm parameter" (default — Mama).
* Set via sk_repl_set_active_vm(); read by sk_repl_run() each iteration.
*===========================================================================*/
static VM *g_repl_active_vm = (void *)0;
void sk_repl_set_active_vm(VM *vm) { g_repl_active_vm = vm; }
VM *sk_repl_get_active_vm(void) { return g_repl_active_vm; }
/*===========================================================================
* Currently attached home-blocks device: mirrors g_repl_active_vm's own
* shape (FABRIC-2.md §F.9's own precedent for this exact accessor). Set
* once sk_repl_idle()'s own attach handling confirms HOMEBLOCKS_SIG_OK
* below; cleared on detach. RUNCAP (§F.6/§F.18) and, later, BINDSTEP's
* re-verify-live check (§F.9) both need this -- neither lives in this
* file, and usb_blk_dev/xdev below are function-static, invisible outside
* sk_repl_idle() without an accessor like this one.
*===========================================================================*/
static blkio_dev_t *g_homeblocks_dev = (void *)0;
static homeblocks_sig_t g_homeblocks_sig;
static int g_homeblocks_sig_valid = 0;
blkio_dev_t *sk_repl_get_homeblocks_dev(void) {
return g_homeblocks_sig_valid ? g_homeblocks_dev : (void *)0;
}
const homeblocks_sig_t *sk_repl_get_homeblocks_sig(void) {
return g_homeblocks_sig_valid ? &g_homeblocks_sig : (void *)0;
}
/* The currently attached USB block device, regardless of whether it
* checks out as a recognized home-blocks drive -- MINT (§F.8/§F.19)
* targets a blank/unminted drive, which by definition never sets
* g_homeblocks_dev above (that only latches on HOMEBLOCKS_SIG_OK).
* Set once blk_subsys_attach_device() succeeds below, cleared on detach
* alongside g_homeblocks_dev. */
static blkio_dev_t *g_attached_blk_dev = (void *)0;
blkio_dev_t *sk_repl_get_attached_blk_dev(void) {
return g_attached_blk_dev;
}
/*===========================================================================
* Idle heartbeat service
*
* Called from sk_console_readline()/sk_console_getkey() when heartbeat_ticks() has advanced by at least
* SK_IDLE_BEAT_INTERVAL since the last service call. Extend this function
* as higher-level subsystems (msg_fabric, capsule scheduler) come online.
*
* TODO: cadence policy and subsystem dispatch belong in Compudynamics once
* that layer governs cooperative VM execution.
*===========================================================================*/
#define SK_IDLE_BEAT_INTERVAL 100u /* ticks between idle service calls (1 s at 100 Hz) */
static uint64_t g_last_beat_tick; /* zero-initialized (BSS) */
/* Cursor blink, 2026-09-05: the framebuffer cursor (now a thin vertical
* bar, vt100.c's vt100_draw_cursor()) blinks on/off every
* SK_CURSOR_BLINK_INTERVAL ticks while sk_console_readline()'s idle loop
* is spinning -- i.e. whenever nothing has been typed for that long,
* whether sitting at a bare prompt or paused mid-edit. g_cursor_visible
* tracks which half of the blink cycle is current; sk_cursor_show() below
* is the single place that resets the cycle back to "on" and redraws --
* every deterministic draw site (fresh prompt, echoed character,
* backspace) calls it instead of vt100_draw_cursor() directly, so typing
* always shows a solid cursor rather than possibly landing mid-blink. */
#define SK_CURSOR_BLINK_INTERVAL 50u /* ticks between blink toggles (500 ms at 100 Hz) */
static uint64_t g_cursor_blink_tick; /* zero-initialized (BSS) */
static int g_cursor_visible = 1;
static void sk_cursor_show(void)
{
g_cursor_visible = 1;
g_cursor_blink_tick = heartbeat_ticks();
console_fb_draw_cursor();
}
/* Reentrancy guards for the MSG-TICK pump inside sk_repl_idle().
*
* sk_repl_idle() runs vm_interpret(mama, ...) (below) to VM-EXEC MSG-TICK
* into every live child VM (FABRIC-2.md Phase C). But sk_repl_idle() is
* itself called from the blocking KEY/EXPECT/QUERY reads (sk_console_getkey()
* / sk_console_readline(), which run *from inside* the executing VM's own
* vm_interpret once a FORTH word reads input mid-line). vm_interpret() is
* not reentrant -- it resets the VM's single input_buffer/input_pos/
* input_length (vm_core.c) on entry. Calling vm_interpret(mama, ...) at that
* point would re-enter mama's interpreter mid-parse and silently truncate
* the rest of the line; if the pump's own MSG-TICK work then triggers another
* blocking read, it would also recurse unboundedly. Two flags keep the pump
* off every path that could re-enter an interpreter:
* - g_mama_interpreting: set while Hera is executing a dispatched line, so
* the pump defers to the next safe (prompt) boundary.
* - g_idle_pump_active: belt-and-suspenders; stops recursive re-entry
* from inside the pump's own vm_interpret call.
*/
static int g_mama_interpreting; /* zero-initialized (BSS) */
static int g_idle_pump_active; /* zero-initialized (BSS) */
static void sk_repl_idle(VM *active_vm)
{
/* Close any dangling output line before this bottom half emits its own
* chatter (xhci attach/detach progress, block-subsystem notices). If we
* are mid-prompt-line -- the REPL started the attach while sitting at
* "ok> " -- a bare console_println() would otherwise glue its text onto
* the prompt and inherit no {VMName} prefix (g_line_start is 0). A
* fresh line first keeps every idle line prefix-tagged and readable,
* matching what an interactive typing session expects. No-op when the
* console is already at a line boundary.
*
* FABRIC-2.md §I.9 fix, 2026-09-05: this newline is now deferred (see
* console_ensure_line_start()'s own doc comment) -- it only actually
* reaches the console if something below really prints. tx_before_idle
* lets this function tell "nothing happened" apart from "something did"
* the same way the reanchor check further up this file already does,
* so a beat with nothing to report can cancel the deferred newline
* before returning, leaving the bare prompt line completely untouched
* instead of visibly snapping it to a fresh blank line every ~1s. */
console_ensure_line_start();
uint64_t tx_before_idle = console_tx_count();
/* Artemis Milestone 2d: xHCI Event Ring servicing. This is exactly the
* "interrupt-driven, coarse cadence, cheap early-exit" trigger Section
* U item 6 asked for -- xhci_poll_events() is a no-op read (loop
* condition false immediately) whenever nothing is pending, and this
* hook already runs at a deliberately coarser cadence than the raw
* per-tick ISR (SK_IDLE_BEAT_INTERVAL, ~1s at 100Hz), matching "quick
* check... done... ignore what we can... done." A no-op call if no
* controller was found/brought up (xhci_bringup() never latched a
* device). */
xhci_poll_events();
/* Milestone 2h: a Mass Storage/BOT device finished SET_CONFIGURATION
* during the xhci_poll_events() call just above -- run the
* synchronous capacity query + block-subsystem attach here, strictly
* after that call has already returned (see bot_msc_attach_pending's
* own doc comment in xhci_driver.h for why: xhci_bot_wait_for_idle()'s
* busy-wait -- which blkio_usb_open_msc() uses internally -- must
* never run from inside xhci_poll_events()'s own call frame). */
xhci_dev_t *xdev = xhci_get_dev();
static blkio_dev_t usb_blk_dev; /* single-device scope, matching the xHCI
* driver's own; referenced by both the
* attach and detach handling below. */
if (xdev && xdev->bot_msc_attach_pending) {
xdev->bot_msc_attach_pending = 0;
uint32_t slot_id = xdev->bot_msc_attach_slot_id;
int rc = blkio_usb_open_msc(&usb_blk_dev, xdev, slot_id);
if (rc == 0) {
/* FABRIC-2.md Milestone 4: warn on blank/foreign/unrecognized
* media -- the "warn" half. No "refuse" half yet: blkio_usb.c
* has no SCSI WRITE(10) support at all (Milestone 2's biggest
* open item), so there is no write path today to refuse --
* only read-only attach, which is also the general-purpose USB
* block I/O path this repo already relies on for unrelated
* testing, not exclusively a home-blocks identity workflow.
* Refusing attach on blank media here would break that
* legitimate use without protecting anything real yet. Refuse
* belongs on the write path, once WRITE(10) gives it something
* to gate.
*
* HOMEBLOCKS_SIG_START_FBLOCK (devblock 1): the real, final
* location -- GPT was dropped permanently, this is not an
* interim value (FABRIC-2.md §F.8/§F.13). */
homeblocks_sig_t sig;
homeblocks_sig_result_t sig_rc =
homeblocks_sig_check(&usb_blk_dev, HOMEBLOCKS_SIG_START_FBLOCK, &sig);
switch (sig_rc) {
case HOMEBLOCKS_SIG_OK:
log_message(LOG_DEBUG, "xhci: USB drive recognized as a home-blocks drive");
g_homeblocks_dev = &usb_blk_dev;
g_homeblocks_sig = sig;
g_homeblocks_sig_valid = 1;
break;
case HOMEBLOCKS_SIG_BLANK:
log_message(LOG_DEBUG, "xhci: USB drive not recognized (blank or foreign media) -- read-only general use only");
break;
case HOMEBLOCKS_SIG_BAD_VERSION:
log_message(LOG_WARN, "xhci: USB drive has a home-blocks header of an unrecognized version -- read-only general use only");
break;
case HOMEBLOCKS_SIG_BAD_CRC:
log_message(LOG_WARN, "xhci: USB drive has a home-blocks header that fails its checksum (corrupt or tampered) -- read-only general use only");
break;
case HOMEBLOCKS_SIG_READ_ERROR:
log_message(LOG_ERROR, "xhci: USB drive signature check failed to read the device -- read-only general use only");
break;
}
/* FABRIC-2.md §F.20/§F.21: Zuse is thumbdrive-resident now,
* not system-resident -- this is the only point in the boot
* lifecycle a just-attached drive's sig result is known, so
* genesis-mint/attach-authenticate has to happen from here,
* not as a one-shot kernel_main.c step (a thumbdrive can't
* be detected before the REPL's own idle polling exists to
* detect it). No-ops immediately if Zuse already has a real
* identity this boot. */
capsule_zuse_boot_try_attach(&usb_blk_dev, sig_rc, &sig, (VM *)sk_get_mama_vm());
/* FABRIC-2.md §F.5/§F.23 (WIREBIND): the real thumbdrive-
* attach call site for a regular (non-Zuse) identity --
* verify-then-birth-then-pair, replacing the RUNCAP-TEST/
* PAIR-TEST diagnostic words that exercised each piece by
* hand. Only meaningful for a drive that actually checked
* out (HOMEBLOCKS_SIG_OK); capsule_wirebind_try_attach()
* itself no-ops for a genesis-mode Zuse drive (no cert
* region) or before Zuse has authenticated this boot. */
if (sig_rc == HOMEBLOCKS_SIG_OK) {
capsule_wirebind_try_attach(&usb_blk_dev, &sig, (VM *)sk_get_mama_vm());
}
}
if (rc == 0 && blk_subsys_attach_device(&usb_blk_dev) == BLK_OK) {
xdev->bot_msc_attached = 1;
g_attached_blk_dev = &usb_blk_dev;
} else {
log_message(LOG_ERROR, "xhci: USB MSC block-subsystem attach failed");
}
}
/* Milestone 2h hot-detach: the device disconnected (PORTSC, inside the
* xhci_poll_events() call above) after having actually attached.
* blk_subsys_detach_device() is local block_subsystem.c bookkeeping --
* no device round-trip, so it wouldn't strictly need to run outside
* xhci_poll_events()'s own call frame -- but handling it here anyway
* matches the attach path's shape and keeps xhci.c decoupled from
* block_subsystem.c (see bot_msc_detach_pending's own doc comment). */
if (xdev && xdev->bot_msc_detach_pending) {
xdev->bot_msc_detach_pending = 0;
blk_subsys_detach_device(&usb_blk_dev);
if (g_homeblocks_dev == &usb_blk_dev) {
g_homeblocks_dev = (void *)0;
g_homeblocks_sig_valid = 0;
}
if (g_attached_blk_dev == &usb_blk_dev) {
g_attached_blk_dev = (void *)0;
}
/* FABRIC-2.md §F.10 decision 2 (UNCLEAN, closed alongside EJECT):
* the device is already gone -- no-op if WIREBIND never had
* anything tracked (general-purpose USB use, not a home-blocks
* identity drive). */
capsule_wirebind_unclean_detach();
/* FABRIC-2.md §I.8, re-scoped 2026-09-04: Zuse logs out on device
* removal exactly like a WIREBIND user -- no-op if the device
* that just left wasn't hers. */
capsule_zuse_boot_logout((VM *)sk_get_mama_vm());
}
/* FABRIC-0.md/FABRIC-1.md Section V item 6: "a cheap 'anything dirty?
* no? done' block-sync check", the same "interrupt-driven, coarse
* cadence, cheap early-exit" trigger shape as the xHCI servicing
* above -- this was the one piece of that design already fully
* specified and waiting for this hook to actually be non-empty.
* blk_vm_flush_all() (block_words.c, the same code SAVE-BUFFERS
* itself runs) is cheap to call when nothing is dirty -- every
* check inside is a small fixed-size scan, no disk I/O happens
* unless something genuinely needs writing -- so no separate
* "is anything dirty" pre-check is needed here.
*
* active_vm is passed in by the caller (sk_console_readline(), itself passed
* through from sk_repl_run()/sk_repl_step()'s own already-resolved
* VM) rather than read via sk_repl_get_active_vm() here -- that
* accessor returns NULL whenever Tripod's USE word hasn't redirected
* it, which is the common case, not "no VM is active." An earlier
* version of this code called sk_repl_get_active_vm() directly and
* silently no-op'd for exactly that reason, confirmed live: a BUFFER
* write with no UPDATE, followed by an idle wait and an abrupt kill,
* did not survive a reboot until this fix. */
blk_vm_flush_all(active_vm);
/* FABRIC-2.md §I.2, built 2026-09-04: heat/wear-leveling migration
* trigger -- one linear scan of Artemis's own device per idle tick
* (same ~1 Hz SK_IDLE_BEAT_INTERVAL cadence this whole function
* already runs at, chosen so a hot devblock is caught proactively
* rather than only on a failed write). See block_subsystem.c's own
* doc comment on blk_migration_idle_check() for what's built (heat-
* based relocation) vs. deliberately left open (overflow-triggered
* migration, needs a call site threaded from WIREBIND). */
blk_migration_idle_check();
/* FABRIC-2.md §I.2's own overflow trigger, closed 2026-09-05: the
* call site named above, now built. Same cadence, same idle-tick
* neighbor -- see capsule_wirebind_overflow_idle_check()'s own doc
* comment for what it does and why it's a one-time extension, not a
* growth loop. */
capsule_wirebind_overflow_idle_check();
/* FABRIC-2.md Phase C (2026-08-28): distributed messaging pump. Every
* live VM except Hera herself now owns its own MSG-ARENA/CH-ARENA and
* MSG-TICK word (see capsules/common/messaging.4th) instead of only
* Hermes having one -- "fully distributed, Hera pumps each VM's
* drain" was the confirmed design. Hera is excluded: she never loads
* common:messaging.4th (kernel_main.c's own comment at the Hermes-
* birth call site explains why -- the STADIUM-* primitives it needs
* are deliberately never registered in her own dictionary, to keep
* her dict_hash off item 4.1's baseline), so MSG-TICK is genuinely
* absent there, not just untried. She is the only VM with a
* persistent idle tick, so she walks the registry once per idle beat
* and VM-EXECs MSG-TICK into every OTHER live VM's own dictionary. */
VM *mama = (VM *)sk_get_mama_vm();
/* Reentrancy guard: never run the pump while mama is mid-interpret
* (a dispatched line, or recursively from within the pump's own
* vm_interpret). vm_interpret() clobbers the VM's single input
* buffer, so re-entering it here while KEY/EXPECT/QUERY blocks inside
* a live parse truncates the rest of that line. Deferring the MSG-TICK
* drain to the next prompt boundary is safe -- draining is best-effort
* and simply resumes next beat. */
if (g_mama_interpreting || g_idle_pump_active) {
if (console_tx_count() == tx_before_idle) {
console_cancel_deferred_line_start();
}
return;
}
g_idle_pump_active = 1;
{
uint32_t count = capsule_vm_registry_count();
uint32_t i;
for (i = 0; i < count; i++) {
VMRegistryEntry ent;
if (capsule_vm_registry_get_by_index(i, &ent) != 0) continue;
if (ent.state != VM_STATE_LIVE) continue;
if (ent.vm_ptr == (void *)mama) continue;
static const char PFX[] = "S\" MSG-TICK\" S\" ";
static const char SFX[] = "\" VM-EXEC";
char cmd[128];
size_t p = 0;
size_t name_len = strlen(ent.name);
if (name_len > VM_NAME_MAX - 1u) name_len = VM_NAME_MAX - 1u;
memcpy(cmd + p, PFX, sizeof(PFX) - 1u); p += sizeof(PFX) - 1u;
memcpy(cmd + p, ent.name, name_len); p += name_len;
memcpy(cmd + p, SFX, sizeof(SFX) - 1u); p += sizeof(SFX) - 1u;
cmd[p] = '\0';
vm_interpret(mama, cmd);
}
}
g_idle_pump_active = 0;
if (console_tx_count() == tx_before_idle) {
console_cancel_deferred_line_start();
}
}
/*===========================================================================
* FABRIC-0.md item 4.4v: keyboard-to-REPL bridge.
*
* Translates sk_key_event_poll()'s converged Linux-keycode-namespace
* stream (keyboard_words.c -- one implementation shared with KEY-EVENT,
* live-verified on all three architectures per item 4.3.5f) into the same
* byte stream sk_console_readline() already reads from console_getc(): -1 for
* "nothing ready", else a raw ASCII byte with '\n'/0x7F meaning the same
* thing they mean for the serial path below.
*
* Table covers exactly the keys a line editor needs -- letters, digits,
* the standard US-QWERTY punctuation row, space, enter, backspace, tab
* (for the Ctrl+TAB toggle interception, 4.4y/4.4u step 8) -- not full
* keyboard coverage. Keycodes are Linux input-event-codes.h values,
* confirmed against this build host's own header, not guessed (§25.0
* rule 4). Index 0 means "no mapping"; arrows/F-keys/etc. fall through
* unmapped and are silently dropped, consistent with this REPL's
* append/backspace-only editing model (4.4u: no mid-line cursor
* movement).
*===========================================================================*/
#define SK_KBD_TABLE_SIZE 98u /* highest keycode used below is KEY_RIGHTCTRL=97 */
static const char sk_kbd_unshifted[SK_KBD_TABLE_SIZE] = {
[2]='1',[3]='2',[4]='3',[5]='4',[6]='5',[7]='6',[8]='7',[9]='8',[10]='9',[11]='0',
[12]='-',[13]='=',
[16]='q',[17]='w',[18]='e',[19]='r',[20]='t',[21]='y',[22]='u',[23]='i',[24]='o',[25]='p',
[26]='[',[27]=']',
[30]='a',[31]='s',[32]='d',[33]='f',[34]='g',[35]='h',[36]='j',[37]='k',[38]='l',
[39]=';',[40]='\'',[41]='`',[43]='\\',
[44]='z',[45]='x',[46]='c',[47]='v',[48]='b',[49]='n',[50]='m',
[51]=',',[52]='.',[53]='/',
[57]=' ',
};
static const char sk_kbd_shifted[SK_KBD_TABLE_SIZE] = {
[2]='!',[3]='@',[4]='#',[5]='$',[6]='%',[7]='^',[8]='&',[9]='*',[10]='(',[11]=')',
[12]='_',[13]='+',
[16]='Q',[17]='W',[18]='E',[19]='R',[20]='T',[21]='Y',[22]='U',[23]='I',[24]='O',[25]='P',
[26]='{',[27]='}',
[30]='A',[31]='S',[32]='D',[33]='F',[34]='G',[35]='H',[36]='J',[37]='K',[38]='L',
[39]=':',[40]='"',[41]='~',[43]='|',
[44]='Z',[45]='X',[46]='C',[47]='V',[48]='B',[49]='N',[50]='M',
[51]='<',[52]='>',[53]='?',
[57]=' ',
};
#define SK_KEY_BACKSPACE 14u
#define SK_KEY_TAB 15u
#define SK_KEY_ENTER 28u
#define SK_KEY_LEFTSHIFT 42u
#define SK_KEY_RIGHTSHIFT 54u
#define SK_KEY_LEFTALT 56u
#define SK_KEY_RIGHTALT 100u
static int g_kbd_shift_down; /* zero-initialized (BSS) */
static int g_kbd_alt_down;
/* Drains and translates one physically-typed key. Modifier state persists
* across calls (a real keyboard's shift/alt state is global, not
* per-line). Alt+TAB is intercepted here and drives the graphics/text
* toggle directly (console_fb_toggle_graphics(), the same state-machine
* transition the ALT+TAB FORTH word calls) -- never reaches the line
* buffer as a character either way. */
static int sk_kbd_getc(void)
{
uint16_t keycode;
int pressed;
while (sk_key_event_poll(&keycode, &pressed)) {
if (keycode == SK_KEY_LEFTSHIFT || keycode == SK_KEY_RIGHTSHIFT) {
g_kbd_shift_down = pressed;
continue;
}
if (keycode == SK_KEY_LEFTALT || keycode == SK_KEY_RIGHTALT) {
g_kbd_alt_down = pressed;
continue;
}
if (!pressed) continue; /* only act on press/repeat */
if (keycode == SK_KEY_TAB) {
if (g_kbd_alt_down) console_fb_toggle_graphics();
continue; /* bare TAB: not mapped, same as arrows/F-keys */
}
if (keycode == SK_KEY_ENTER) return '\n';
if (keycode == SK_KEY_BACKSPACE) return 0x7F;
if (keycode < SK_KBD_TABLE_SIZE) {
char c = g_kbd_shift_down ? sk_kbd_shifted[keycode] : sk_kbd_unshifted[keycode];
if (c) return (unsigned char)c;
}
/* unmapped keycode -- drop and keep draining */
}
return -1;
}
/* One raw byte from either input source (serial console or the keyboard-
* event bridge), non-blocking, -1 if neither has one ready right now. Not
* itself a FORTH word -- the shared byte-fetch underneath sk_console_getkey()/
* sk_console_key_available() (the standard dictionary's KEY/?TERMINAL, wired
* through shim.c's getchar()) and sk_console_readline() (QUERY/EXPECT, wired
* through shim.c's fgets()) alike. */
static int sk_console_getc_raw(void)
{
int c = console_getc();
if (c < 0) c = sk_kbd_getc(); /* FABRIC-0.md 4.4v: second source, same buffer */
return c;
}
/* One-byte pushback so sk_console_key_available() can peek without losing
* the byte -- ?TERMINAL must be non-destructive (a caller checks readiness,
* then still expects KEY to return that same key). */
static int g_console_pending_key = -1;
/* KEY's real body (shim.c's getchar() calls this): blocks until a key is
* available, servicing the heartbeat/idle loop while waiting -- same
* cadence sk_console_readline() already uses below, so a KEY call mid-word
* never stalls the heartbeat or Hera's own idle dispatch. No echo -- that's
* the caller's job, same as any standard KEY implementation. */
int sk_console_getkey(VM *active_vm)
{
for (;;) {
int c;
if (g_console_pending_key >= 0) {
c = g_console_pending_key;
g_console_pending_key = -1;
} else {
c = sk_console_getc_raw();
}
if (c >= 0) return c;
heartbeat_service();
uint64_t now = heartbeat_ticks();
if (now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) {
g_last_beat_tick = now;
sk_repl_idle(active_vm);
}
arch_relax();
}
}
/* ?TERMINAL's real body (sf_terminal_ready(), shim.c): non-blocking peek --
* a single poll, no idle-servicing loop (a false result must return
* immediately, not block). Buffers a found byte in g_console_pending_key so
* a following sk_console_getkey() returns the exact same key, not a
* different/later one. */
int sk_console_key_available(void)
{
if (g_console_pending_key >= 0) return 1;
int c = sk_console_getc_raw();
if (c >= 0) { g_console_pending_key = c; return 1; }
return 0;
}
/*===========================================================================
* sk_console_readline - line read from serial console with echo
*
* Non-blocking poll of console_getc(). While no character is ready the idle
* spin services the adaptive heartbeat at SK_IDLE_BEAT_INTERVAL tick cadence.
* Supports backspace (0x7F and \b) and ignores other control characters.
* Returns the number of characters placed in buf (not counting '\0').
*
* Public (declared in repl.h): shim.c's fgets()/QUERY's own real body call
* this directly -- same line-editing behavior for a mid-word EXPECT/QUERY as
* for the REPL's own top-level prompt, since it's the same underlying
* console. Any g_console_pending_key left over from a ?TERMINAL peek is
* consumed first so a line read never drops a byte ?TERMINAL already saw.
* @param reanchor_prompt nonzero from the REPL's own prompt sites (which
* print SK_PROMPT_TEXT immediately before): re-print the prompt whenever an
* idle bottom half wrote to the console while this call blocked at the bare
* prompt (see the re-anchor block in the idle branch). shim.c's fgets()
* passes 0 -- its prompt context is caller-owned.
*===========================================================================*/
int sk_console_readline(char* buf, int size, VM* active_vm, int reanchor_prompt)
{
int n = 0;
/* TX counter value right after the caller printed its prompt. Any
* console output that lands while this readline blocks (heartbeat
* status, sk_repl_idle()'s USB attach/detach chatter) pushes the
* counter past this mark and away from a bare prompt; when that
* happens, re-anchor the prompt (below). */
uint64_t prompt_tx_mark = console_tx_count();
buf[0] = '\0';
sk_cursor_show(); /* show the cursor at the bare prompt, before any input */
for (;;) {
int c;
if (g_console_pending_key >= 0) {
c = g_console_pending_key;
g_console_pending_key = -1;
} else {
c = sk_console_getc_raw();
}
if (c < 0) {
/* Service the heartbeat bottom half every idle iteration, not
* gated by SK_IDLE_BEAT_INTERVAL (item 0.8, FABRIC-0.md §26):
* heartbeat_service() drains at most one latched sample per
* call, so a coarse gate here would silently lose or merge
* samples between ISR-latched ticks. sk_repl_idle() below is
* a separate, deliberately coarser cadence for higher-level
* subsystem dispatch, unrelated to sample fidelity. */
heartbeat_service();
uint64_t now = heartbeat_ticks();
/* n == 0 gate: sk_repl_idle() opens with console_ensure_line_start(),
* closing off a dangling prompt line before any chatter it might
* print (xhci attach/detach, block-sync, MSG-TICK pump). Before the
* FABRIC-2.md §I.9 fix (2026-09-05), that newline was unconditional
* and immediate, so it fired on every elapsed SK_IDLE_BEAT_INTERVAL
* regardless of whether sk_repl_idle() actually had anything to
* print -- including mid-edit (n > 0, characters typed but Enter
* not yet pressed), visually snapping the in-progress line to a
* fresh blank one, indistinguishable from Enter having been
* pressed. console_ensure_line_start()'s newline is now deferred
* and self-cancelling when nothing follows it (console.c), which
* fixes that regardless of n -- but this gate is kept for its own,
* independent reason: deferring the *whole* idle beat while a line
* is being edited (same n > 0 guard the prompt reanchor below
* already uses) means a genuine xhci/block-sync/MSG-TICK event
* cannot interrupt output mid-line while the user is actively
* typing, only delaying that servicing by at most one more
* interval, which its own "coarse cadence, cheap early-exit"
* design already tolerates. */
if (n == 0 && now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) {
g_last_beat_tick = now;
sk_repl_idle(active_vm);
}
/* Blink the cursor while idle (no key ready this iteration),
* regardless of n -- a real terminal blinks whether sitting at
* a bare prompt or paused mid-edit. sk_cursor_show() (called
* from every deterministic draw site below and at entry) resets
* this cycle to "on" on every real keystroke, so typing never
* looks like it landed mid-blink. */
if (now - g_cursor_blink_tick >= SK_CURSOR_BLINK_INTERVAL) {
g_cursor_blink_tick = now;
g_cursor_visible = !g_cursor_visible;
if (g_cursor_visible) console_fb_draw_cursor();
else console_fb_erase_cursor();
}
/*
* Re-anchor the prompt (FABRIC-0.md 4.4a unified prompt: print
* only "ok> " here -- console_putc() auto-prefixes the current
* [VMName] on a fresh line). When an idle bottom half above
* pushed output past prompt_tx_mark, the console cursor is now
* below/after new lines and the "ok> " the caller printed has
* been scrolled or buried -- once the flood passes, the screen
* and serial log would end on a stale line with no prompt
* (FABRIC-2.md: the bare prompt must be the last thing shown
* while the REPL sits idle). Reprinting it restores that
* invariant. Skipped while a line is being edited (n > 0) so
* partial echo stays attached to its own prompt; shim.c's
* fgets() (QUERY/EXPECT/ACCEPT) calls in with reanchor_prompt
* == 0 for the same reason -- its prompt line is caller-owned
* text, not the REPL's. Each silent beat leaves the mark
* unchanged, so the final state after the chatter dies down is
* a fresh prompt on the last visible line, cursor on it.
*/
if (reanchor_prompt && n == 0 &&
console_tx_count() != prompt_tx_mark)
{
sk_print_prompt();
sk_cursor_show();
prompt_tx_mark = console_tx_count();
}
/*
* Do NOT use hlt here: QEMU single-threaded TCG can't process
* its APIC timer callbacks while the guest CPU is halted (the
* event loop and the TCG thread share the same OS thread).
* Interrupts are delivered at TB boundaries in a tight loop.
* On real hardware a wfi/hlt would be appropriate; add it here
* under an #ifdef REAL_HARDWARE guard when that path is needed.
*/
arch_relax(); /* PAUSE — reduce power, maintain tight poll */
continue;
}
if (c == '\r' || c == '\n') {
console_fb_erase_cursor(); /* leaving this cell without drawing a char over it */
console_putc('\n');
break;
}
/* backspace: DEL (0x7F) or BS (0x08) */
if ((c == 0x7F || c == '\b') && n > 0) {
n--;
buf[n] = '\0';
/* VT100 erase: move back, overwrite with space, move back again */
console_putc('\b');
console_putc(' ');
console_putc('\b');
sk_cursor_show();
continue;
}
if (c < 0x20) continue; /* ignore other control characters */
if (n >= size - 1) continue; /* buffer full — drop character */
buf[n++] = (char)c;
buf[n] = '\0';
console_putc((char)c); /* echo */
sk_cursor_show();
}
buf[n] = '\0';
return n;
}
/*===========================================================================
* sk_repl - FORTH REPL
*
* FABRIC-2.md §F.20/§F.21 (2026-08-28): the unauthenticated emergency-CLI
* ACL bypass this REPL used to grant itself on Hera's own bare prompt is
* retired -- every word runs under ordinary ACL enforcement here now,
* console identity included. emergency_console still exists as a field
* (vm.h) and is still set, briefly, by the genuine C-level VM fault
* handler (EMERGENCY_CONSOLE_ENABLED build flag) for crash recovery --
* that's a distinct, narrower mechanism this REPL no longer touches.
*
* Mirrors vm_repl() from src/repl.c:
* - Reads a line via sk_console_readline (non-blocking, heartbeat-serviced)
* - Calls vm_interpret
* - Prints " ok" or " ERROR"
* - When EMERGENCY_CONSOLE_ENABLED=1: resets vm->error and loops (recovery)
* - When EMERGENCY_CONSOLE_ENABLED=0: halts VM on error (no fallthrough surface)
*===========================================================================*/
#if !EMERGENCY_CONSOLE_ENABLED
static void sk_fault_handler(VM *vm) {
console_println("VM fault — emergency console disabled; halting");
vm->halted = 1;
}
#endif
/*===========================================================================
* sk_repl_dispatch_line - console-VM + user-VM pair relay (FABRIC-2.md
* Phase F, 2026-08-28).
*
* If `vm`'s own registered name has a live "<name>~user" counterpart,
* this is a console session: relay the raw line as a real, async
* CONSOLE-CMD-EVENT message (common:messaging.4th) instead of
* interpreting it directly -- "every line is a message," not a
* C-level redirect. This is one particular consumer of the general
* VM-to-VM messaging system built in Phase C: any VM can already
* MSG-SEND to any other VM for its own reasons regardless of a human
* ever being at a physical console at all; this hook only wires the
* physical-terminal-input path into that same general mechanism, it
* doesn't gate or replace it.
*
* Falls back to direct vm_interpret() (today's unchanged behavior) when
* there's no live paired user VM, or when the line contains a `"`
* character this simple S"-embedding can't safely carry yet (a known
* v1 limitation -- warned about, not silently mishandled).
*===========================================================================*/
/* USE (FABRIC-2.md §F.24) is a REPL-control word, not a command for
* whatever VM happens to be paired to a console -- it must always run
* on the active VM directly, never get relayed as a message. Real
* FORTH syntax always puts USE last (S" name" USE), so a trailing-
* token match is a reliable, non-tokenizing-required check: trim
* trailing whitespace, then confirm the line ends with "USE" as its
* own word (preceded by whitespace or the whole line). */
static int sk_repl_line_calls_use(const char *input)
{
size_t len = strlen(input);
while (len > 0 && (input[len - 1] == ' ' || input[len - 1] == '\t')) len--;
if (len < 3) return 0;
if (input[len - 3] != 'U' || input[len - 2] != 'S' || input[len - 1] != 'E') return 0;
return (len == 3) || (input[len - 4] == ' ' || input[len - 4] == '\t');
}
static void sk_repl_dispatch_line(VM *vm, const char *input)
{
/* H1 reentrancy guard: while this dispatched line executes on Hera
* herself, sk_repl_idle() must defer its MSG-TICK pump -- calling
* vm_interpret(mama, ...) from inside a mid-line KEY/EXPECT would
* re-enter mama's interpreter and clobber its in-flight input buffer
* (see the guard's comment at sk_repl_idle()). A child VM's console
* turn leaves mama idle, so the pump stays safe there and the guard is
* only latched for Hera. */
int on_mama = (vm == (VM *)sk_get_mama_vm());
int saved = g_mama_interpreting;
if (on_mama) g_mama_interpreting = 1;
if (sk_repl_line_calls_use(input)) {
vm_interpret(vm, input);
goto out;
}
const char *vn = console_get_vm_name();
if (vn) {
char paired_name[VM_NAME_MAX + 8];
size_t vnlen = strlen(vn);
if (vnlen + 6 <= sizeof(paired_name)) {
memcpy(paired_name, vn, vnlen);
memcpy(paired_name + vnlen, "~user", 6); /* includes NUL */
VMRegistryEntry paired;
if (capsule_vm_find_by_name(paired_name, &paired) == 0 &&
paired.state == VM_STATE_LIVE) {
if (strchr(input, '"')) {
console_println("console: line contains '\"' -- can't relay "
"as a message safely yet, interpreting directly");
} else {
char cmd[INPUT_BUFFER_SIZE + 64];
/* to-index 3: the fixed convention this console's own
* VM-NAME-REG entry for its paired user VM uses (set
* once at pairing time -- see the pairing word). */
int n = snprintf(cmd, sizeof(cmd),
"CONSOLE-CMD-EVENT 0 3 S\" %s\" 0 MSG-SEND", input);
if (n > 0 && (size_t)n < sizeof(cmd)) {
vm_interpret(vm, cmd);
goto out;
}
}
}
}
}
vm_interpret(vm, input);
out:
g_mama_interpreting = saved;
}
/*===========================================================================
* sk_repl_step - Execute one REPL turn on a VM and return.
*
* Prints the VM's prompt, reads one input line, interprets it, prints
* ok/ERROR, then returns. Used by the Compudynamics VM-STEP primitive
* so Hera can give a single REPL quantum to any child VM without
* surrendering control for the full sk_repl_run() loop.
*
* Returns 1 if the VM is still running, 0 if it halted during this turn.
*===========================================================================*/
int sk_repl_step(VM *vm)
{
char input[INPUT_BUFFER_SIZE]; /* FABRIC-0.md 4.4w: matches the strip's input width */
if (!vm || vm->halted) return 0;
{
/* Unified prompt (FABRIC-0.md 4.4a): console_putc()'s existing per-line
* "[VMName] " prefix (console.c, g_active_vm_name) already supplies the
* bracket -- print only "ok> " here, don't build a second one.
* emergency_console is no longer set from here (FABRIC-2.md §F.20/
* §F.21: the emergency-CLI ACL bypass is retired) -- it's driven
* only by the genuine C-level fault handler now (vm.c's own
* emergency-fault-recovery use, EMERGENCY_CONSOLE_ENABLED). Every
* word run from this REPL, Hera's bare prompt included, goes
* through ordinary ACL enforcement. FABRIC-0.md 4.4s (2026-09-04):
* sk_print_prompt() extends this with a "(user)" segment when an
* identity is attached -- see its own doc comment. */
sk_print_prompt();
}
sk_console_readline(input, sizeof(input), vm, 1);
if (input[0] == '\0') {
console_puts(" ok\n");
return vm->halted ? 0 : 1;
}
sk_repl_dispatch_line(vm, input);
/* ABORT stops mid-line but leaves the flag set for the caller to
* consume -- this REPL step is that boundary. Clear it here so the
* next line isn't silently refused by vm_interpret's own check. */
vm->abort_requested = 0;
if (vm->error) {
console_puts(" ERROR\n");
vm->error = 0;
} else {
console_puts(" ok\n");
}
return vm->halted ? 0 : 1;
}
void sk_repl_run(VM *vm)
{
char input[INPUT_BUFFER_SIZE]; /* FABRIC-0.md 4.4w: matches the strip's input width */
VM *active;
vm->halted = 0;
while (!vm->halted) {
/* USE may redirect input to a different VM each iteration */
active = g_repl_active_vm ? g_repl_active_vm : vm;
/* Unified prompt (FABRIC-0.md 4.4a): console_putc()'s existing per-line
* "[VMName] " prefix (console.c, g_active_vm_name) already supplies the
* bracket -- print only "ok> " here, don't build a second one.
* emergency_console is no longer set from here (FABRIC-2.md §F.20/
* §F.21: the emergency-CLI ACL bypass is retired) -- see sk_repl_
* step()'s matching comment above. FABRIC-0.md 4.4s (2026-09-04):
* sk_print_prompt() extends this with a "(user)" segment. */
sk_print_prompt();
sk_console_readline(input, sizeof(input), active, 1);
if (input[0] == '\0') {
console_puts(" ok\n");
continue;
}
sk_repl_dispatch_line(active, input);
/* ABORT stops mid-line but leaves the flag set for the caller to
* consume -- this REPL step is that boundary. Clear it here so the
* next line isn't silently refused by vm_interpret's own check. */
active->abort_requested = 0;
if (active->error) {
console_puts(" ERROR\n");
active->error = 0;
} else {
console_puts(" ok\n");
}
}
}
void sk_repl(VM *vm)
{
/* FABRIC-0.md item 4.4j: boot and POST (both already returned by the time
* sk_repl() is called) stay on font_8x16.c/VT100 by design; the
* interactive REPL -- this function -- is the boundary where TTF-TEXT
* takes over. One-shot: console_fb_enable_ttf() no-ops on any later
* call. */
console_fb_enable_ttf();
console_println(lithos_version);
console_puts("StarForth Version "); console_println(STARFORTH_VERSION);
console_println("");
console_println("StarForth Emergency CLI");
console_println("FORTH-79 interpreter — type BYE or power off to exit");
console_println("");
sk_repl_run(vm);
}