Files
LithosAnanke/src/starkernel/hal/vt100.c
T
Robert Allan JamesandClaude Sonnet 5 3396e9a7b4 FABRIC.md item 4.4ac: scrollback during boot/POST, not just from REPL
vt100.c: font_8x16/bitmap-mode boot output previously had no scrollback
at all -- g_shadow/g_ring were only allocated in vt100_enable_ttf(), so
POST/self-test/heartbeat text was gone the instant it scrolled off,
recoverable only from the serial log. Gives boot mode its own ring/
shadow pair (bitmap cell geometry, 4096-line capacity), frozen as a
snapshot the moment vt100_enable_ttf() switches to the TTF-geometry
pair, per the two-independent-rings design scoped with Captain Bob.
scrollback_line_at()/scrollback_redraw()/vt100_scroll_back() now walk
all four segments (boot ring, boot shadow, TTF ring, TTF shadow) as one
continuous history, so PgUp from the REPL reaches back through POST.

Three-arch QEMU boot + logs clean (amd64/aarch64/riscv64, no faults, no
dictionary/parity regressions). Visual verification that PgUp actually
recalls POST text still needs an interactive GTK screendump -- noted as
open in FABRIC.md, same pattern as 4.4ab's screendump.

Also includes BLOCK_MAP.md/artemis.img regenerated by these builds, and
the acceptance-boot logs (plus stray logs from an earlier QEMU-instance
collision during testing -- kept per repo convention, logs are audit
artifacts, not deleted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 18:21:31 -04:00

1085 lines
42 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.
*/
/**
* vt100.c — Full-color ANSI / VT100 terminal state machine
*
* Parser states
* ─────────────
* NORMAL plain text characters
* ESC received 0x1B, waiting for next byte
* CSI received ESC [ , accumulating parameter digits / intermediates
* ESC_HASH received ESC # (line attributes — we ignore, eat next byte)
* ESC_CHAR received ESC ( or ESC ) (charset designation — ignored)
*
* Supported CSI sequences
* ───────────────────────
* CUU A cursor up
* CUD B cursor down
* CUF C cursor forward
* CUB D cursor back
* CUP H cursor position (row ; col)
* ED J erase display (0=below 1=above 2=all)
* EL K erase line (0=right 1=left 2=all)
* SGR m select graphic rendition (see header)
* SCP s save cursor position
* RCP u restore cursor position
*
* ESC sequences (2-char)
* ESC 7 save cursor (DEC)
* ESC 8 restore cursor (DEC)
* ESC M reverse index (scroll down)
* ESC c full reset
*/
#include "vt100.h"
#include "framebuffer.h"
#include "starkernel/ttf.h"
#include "starkernel/capsule_generated.h"
#include "starkernel/kmalloc.h"
#include "log.h"
#include <stdint.h>
#include <string.h>
/* Maximum CSI parameters */
#define VT100_MAX_PARAMS 16
/* Underline scanline (row 14 of 16) -- font_8x16 mode only; see
* draw_cursor_glyph()'s mode split. */
#define UNDERLINE_ROW 14u
/* -----------------------------------------------------------------------
* FABRIC.md item 4.4j: TTF glyph backend, active only from
* vt100_enable_ttf() onward (boot/POST stays font_8x16.c).
*
* Point size and cell geometry, decided final by item 4.4m (20px text,
* 96px REPL strip = 4 lines). Chosen so 4.4i's confirmed hmtx result (all
* printable ASCII share one 600/1000-em advance width) divides evenly:
* 600/1000 * 20 = 12px exactly, no fractional-pixel cell width. Cell
* height is size + a fixed descender/leading allowance, not derived from
* hhea (not read by this parser -- see ttf.h).
* --------------------------------------------------------------------- */
#define VT100_TTF_SIZE_PX 20u
#define VT100_TTF_CELL_W_PX 12u /* 600/1000 * VT100_TTF_SIZE_PX */
#define VT100_TTF_CELL_H_PX 24u /* provisional: size + 4px leading/descender */
#define VT100_TTF_CACHE_SLOTS 96u /* covers all 95 printable ASCII, no thrash */
#define VT100_TTF_FONT_CAPSULE "fonts:JetBrainsMono-Regular.ttf"
typedef enum {
VT_GLYPH_BITMAP = 0, /* font_8x16.c, via fb_draw_glyph() */
VT_GLYPH_TTF = 1, /* TTF-TEXT's rasterizer, via ttf_draw_glyph_cell() */
} vt_glyph_mode_t;
static vt_glyph_mode_t g_glyph_mode = VT_GLYPH_BITMAP;
static ttf_font_t g_ttf_font;
static int g_ttf_ready = 0;
static ttf_raster_cache_t g_ttf_cache;
/* FABRIC.md item 4.4y-revised: Alt+TAB graphics/text toggle. When 0, every
* terminal draw call (put_char's glyph blit, erase, scroll) becomes a
* pure logical update -- g_vt's cursor/attributes and the scrollback
* shadow still advance normally, only the actual framebuffer write is
* skipped -- so direct framebuffer/TTF-TEXT drawing shows through
* undisturbed, and vt100_toggle_graphics() can restore the terminal
* exactly as it logically stands once toggled back. Always 1 in bitmap
* mode (boot/POST never hides). */
static int g_terminal_visible = 1;
/* -----------------------------------------------------------------------
* FABRIC.md items 4.4t/4.4o/4.4u/4.4w/4.4x/4.4z tried a 640x480 CANVAS
* box with a separate single-line REPL strip pinned below it -- a
* confined region plus independent border-drawing/geometry bookkeeping
* for both. Reverted 2026-08-12, simplified back to what this comment
* block now describes: the entire framebuffer is one vt100 terminal,
* full width and full height, in both bitmap and TTF glyph modes. No
* origin offset, no box, no strip, no border drawing -- cols/rows are
* simply fb_width()/cell_w() and fb_height()/cell_h(). The REPL prompt is
* just the terminal's last line, exactly like any other scrolling
* terminal, not a separately-rendered region.
* --------------------------------------------------------------------- */
/* -----------------------------------------------------------------------
* FABRIC.md item 4.4q: REPL scrollback. Text-only (not pixel snapshots --
* at 1000 lines x ~cols bytes this is tens of KB; a pixel-snapshot ring
* would be roughly three orders of magnitude larger for no benefit, since
* the only consumer is a redraw). Only active in TTF mode (allocated in
* vt100_enable_ttf(), never during boot/POST) -- font_8x16-mode boot
* output has no scrollback and none is scoped for it.
*
* Scope, decided with Captain Bob 2026-08-11: this is scrollback on
* today's full-screen terminal grid, NOT scoped to confine REPL text to
* the 4.4o 640x480 box -- that confinement (vt100's grid still spans the
* whole screen, not just the box) is a real, separate gap, observed by
* 4.4p and still open; deliberately not fixed here, per that explicit
* scoping decision rather than a silent deferral.
*
* Model: `g_shadow` mirrors exactly what's currently on screen (rows x
* cols characters, updated by every draw_cursor_glyph() call and shifted
* by every scroll_up()); `g_ring` holds every line scroll_up() has ever
* evicted from the top of `g_shadow`, oldest-first, circular once full.
* Conceptually one continuous history: ring (oldest..newest evicted) then
* shadow (oldest..newest on-screen) back-to-back; g_scroll_offset is how
* many lines back from the live (shadow) tail the current view sits.
* Scrolling is always "pick a window into that history and redraw it" --
* no separate pixel-scroll code path for scrollback, so recall is
* trivially consistent with what was actually printed instead of a second
* mechanism bolted onto fb_scroll_rows().
*
* Known limitations, not fixed here: redraw uses the terminal's current
* default fg/bg for every recalled line -- per-cell historical SGR color
* isn't stored, so scrollback recovers text exactly but not color.
* Drawing new live output while g_scroll_offset > 0 is unverified and not
* specially handled -- draw_cursor_glyph() always draws at the live
* cursor position regardless of scroll offset, so it would visually
* corrupt a scrolled-back view; out of scope for this item's verification
* path (sequential FORTH-word injection over serial, never concurrent
* with a live typist -- that's M8's keyboard-input problem, unstarted).
* --------------------------------------------------------------------- */
#define VT100_SCROLLBACK_MAX_LINES 1000u
static char *g_shadow = (void *)0; /* g_vt.rows * g_line_stride bytes */
static char *g_ring = (void *)0; /* g_ring_cap * g_line_stride bytes */
static uint32_t g_line_stride = 0; /* cols + 1 (NUL terminator) */
static uint32_t g_ring_count = 0; /* valid stored lines, <= g_ring_cap */
static uint32_t g_ring_head = 0; /* ring slot index of the oldest stored line */
static uint32_t g_ring_cap = 0; /* active ring capacity -- VT100_BOOT_SCROLLBACK_MAX_LINES
* pre-TTF, VT100_SCROLLBACK_MAX_LINES from vt100_enable_ttf()
* onward; see 4.4ac below */
static uint32_t g_scroll_offset = 0; /* 0 = live view */
/* -----------------------------------------------------------------------
* FABRIC.md item 4.4ac: boot-mode scrollback. font_8x16/bitmap-mode boot
* output (POST, capsule birth, self-tests, heartbeat) previously had no
* scrollback at all -- g_shadow/g_ring above were only ever allocated in
* vt100_enable_ttf(), so anything printed before the REPL took over was
* gone the moment it scrolled off-screen. This block gives boot mode its
* own ring/shadow pair, allocated in vt100_init() at the bitmap cell
* geometry (8x16), separate from the TTF-geometry pair above because the
* two modes have different cell sizes and therefore different
* g_line_stride -- reflowing one buffer's stored lines into the other's
* width was rejected as unnecessary complexity for a text-only recall
* (decided with Captain Bob 2026-08-12).
*
* g_boot_shadow/g_boot_ring are frozen (snapshotted, not appended to)
* the moment vt100_enable_ttf() runs: it points g_shadow/g_ring at fresh
* TTF-geometry buffers, so this file's four scrollback segments become,
* oldest to newest: boot ring, boot shadow (the last screenful of boot
* output, frozen as of the transition), TTF ring, TTF shadow (live).
* scrollback_line_at()/scrollback_redraw() walk all four as one
* continuous history, per Captain Bob's call that PgUp from the REPL
* should reach all the way back through POST, not stop at a seam.
*
* Capacity guessed, not measured: 4096 lines vs. the TTF ring's 1000.
* A real qemu-esp boot transcript observed 2026-08-12 ran to ~8,500
* lines of POST/self-test/heartbeat output -- 4096 covers roughly half
* of that on its own; closing the rest of the gap is expected to come
* from trimming that verbosity (a separate, still-open item), not from
* growing this buffer indefinitely. Revisit if that trim doesn't happen. */
#define VT100_BOOT_SCROLLBACK_MAX_LINES 4096u
static char *g_boot_shadow = (void *)0; /* frozen boot g_shadow, snapshotted at vt100_enable_ttf() */
static char *g_boot_ring = (void *)0; /* frozen boot g_ring */
static uint32_t g_boot_line_stride = 0;
static uint32_t g_boot_ring_count = 0;
static uint32_t g_boot_ring_head = 0;
static uint32_t g_boot_ring_cap = 0;
static uint32_t g_boot_rows = 0; /* g_vt.rows at snapshot time; 0 if boot alloc failed */
/* -----------------------------------------------------------------------
* Parser state enum
* --------------------------------------------------------------------- */
typedef enum {
ST_NORMAL = 0,
ST_ESC,
ST_CSI,
ST_ESC_HASH,
ST_ESC_CHAR,
} ParseState;
/* -----------------------------------------------------------------------
* Terminal state
* --------------------------------------------------------------------- */
typedef struct {
/* Geometry */
uint32_t cols;
uint32_t rows;
/* Cursor */
uint32_t cx; /* column (0-based) */
uint32_t cy; /* row (0-based) */
uint32_t saved_cx;
uint32_t saved_cy;
/* Colors & attributes */
uint32_t fg; /* packed 0x00RRGGBB */
uint32_t bg;
uint32_t def_fg;
uint32_t def_bg;
int bold;
int underline;
int reverse;
/* Escape parser */
ParseState state;
int32_t params[VT100_MAX_PARAMS];
uint32_t param_count;
int param_has_value; /* 1 if current param digit has been seen */
int initialized;
} VT100State;
static VT100State g_vt;
/* -----------------------------------------------------------------------
* Forward declarations
* --------------------------------------------------------------------- */
static void put_char(uint8_t ch);
static void advance_cursor(void);
static void scroll_up(uint32_t lines);
static void erase_display(int mode);
static void erase_line(int mode);
static void set_cursor(uint32_t row, uint32_t col);
static void apply_sgr(void);
/* -----------------------------------------------------------------------
* Internal helpers
* --------------------------------------------------------------------- */
/* Cell dimensions (pixels) — font_8x16.c's fixed 8x16 grid in bitmap mode,
* or the provisional TTF cell (4.4j) once vt100_enable_ttf() has switched
* modes. Mode-aware per 4.4i's finding that the grid was hardcoded to
* font_8x16.c specifically; this is the generalization that item called
* for. */
static uint32_t cell_w(void) { return g_glyph_mode == VT_GLYPH_TTF ? VT100_TTF_CELL_W_PX : fb_cell_w(); }
static uint32_t cell_h(void) { return g_glyph_mode == VT_GLYPH_TTF ? VT100_TTF_CELL_H_PX : fb_cell_h(); }
/* Pixel position of the top-left corner of character cell (cx, cy). */
static uint32_t px_of(uint32_t col) { return col * cell_w(); }
static uint32_t py_of(uint32_t row) { return row * cell_h(); }
/* Effective fg / bg accounting for reverse video */
static uint32_t eff_fg(void) { return g_vt.reverse ? g_vt.bg : g_vt.fg; }
static uint32_t eff_bg(void) { return g_vt.reverse ? g_vt.fg : g_vt.bg; }
/* Draw one glyph via the TTF rasterizer into the cell at pixel (cell_px,
* cell_py). Mirrors fb_draw_glyph()'s opaque-cell contract (every pixel in
* the cell gets either fg or bg, not just the glyph's "ink") by filling
* the whole cell with bg first, then stamping covered bitmap pixels with
* fg. Baseline sits VT100_TTF_SIZE_PX down from the cell's top, matching
* TTF-TEXT's own bitmap-origin convention (ttf.h's
* "glyph baseline sits at pixel (MARGIN, size_px + MARGIN)"). Bitmap
* pixels are clipped to the cell rect -- a glyph's rasterized bounds can
* slightly exceed the nominal advance width, and cells must stay opaque
* neighbors, not bleed into each other. */
static void ttf_draw_glyph_cell(uint32_t cell_px, uint32_t cell_py, uint8_t ch,
uint32_t fg, uint32_t bg)
{
ttf_bitmap_t bmp;
int rc, was_hit;
uint32_t bx, by;
uint32_t baseline_y = cell_py + VT100_TTF_SIZE_PX;
fb_fill_rect(cell_px, cell_py, cell_w(), cell_h(), bg);
if (!g_ttf_ready) return; /* shouldn't happen once VT_GLYPH_TTF is set, defensive only */
rc = ttf_raster_cache_get(&g_ttf_cache, &g_ttf_font, (uint32_t)ch,
VT100_TTF_SIZE_PX, &bmp, &was_hit);
if (rc != TTF_OK) return; /* missing/unrasterizable glyph -- leave cell blank (bg only) */
for (by = 0; by < bmp.height; by++) {
int32_t fb_y = (int32_t)baseline_y + (int32_t)by
- (int32_t)VT100_TTF_SIZE_PX - TTF_CACHE_MARGIN;
if (fb_y < (int32_t)cell_py || fb_y >= (int32_t)(cell_py + cell_h())) continue;
for (bx = 0; bx < bmp.width; bx++) {
int32_t fb_x = (int32_t)cell_px + (int32_t)bx - TTF_CACHE_MARGIN;
if (fb_x < (int32_t)cell_px || fb_x >= (int32_t)(cell_px + cell_w())) continue;
if (bmp.pixels[by * bmp.width + bx]) {
fb_put_pixel((uint32_t)fb_x, (uint32_t)fb_y, fg);
}
}
}
}
static void draw_cursor_glyph(uint8_t ch)
{
uint32_t f = eff_fg();
uint32_t b = eff_bg();
/* FABRIC.md item 4.4q: mirror every drawn character into the
* scrollback shadow buffer, when active. */
if (g_shadow && g_vt.cy < g_vt.rows && g_vt.cx < g_vt.cols) {
g_shadow[(size_t)g_vt.cy * g_line_stride + g_vt.cx] = (char)ch;
}
if (g_vt.bold) {
/* Lighten fg for bold */
uint32_t r = FB_R(f);
uint32_t g = FB_G(f);
uint32_t bv= FB_B(f);
r = r > 0xAAu ? 0xFFu : (r + 0x55u > 0xFFu ? 0xFFu : r + 0x55u);
g = g > 0xAAu ? 0xFFu : (g + 0x55u > 0xFFu ? 0xFFu : g + 0x55u);
bv= bv> 0xAAu ? 0xFFu : (bv+ 0x55u > 0xFFu ? 0xFFu : bv+ 0x55u);
f = FB_RGB(r, g, bv);
}
if (!g_terminal_visible) return; /* 4.4y-revised: graphics mode -- logical state
* only (already updated above), no framebuffer write */
if (g_glyph_mode == VT_GLYPH_TTF) {
ttf_draw_glyph_cell(px_of(g_vt.cx), py_of(g_vt.cy), ch, f, b);
} else {
fb_draw_glyph(px_of(g_vt.cx), py_of(g_vt.cy), ch, f, b);
}
if (g_vt.underline) {
/* font_8x16 mode: underline sits at UNDERLINE_ROW (row 14 of 16)
* of that font's fixed grid, scaled. TTF mode: no equivalent fixed
* row exists (this parser doesn't read hhea underline metrics --
* see ttf.h) -- placed 2px below the baseline instead, a
* provisional choice like the rest of 4.4j's TTF cell geometry. */
uint32_t scale = g_glyph_mode == VT_GLYPH_TTF ? 1u : cell_h() / 16u;
uint32_t ul_y = g_glyph_mode == VT_GLYPH_TTF
? py_of(g_vt.cy) + VT100_TTF_SIZE_PX + 2u
: py_of(g_vt.cy) + UNDERLINE_ROW * scale;
fb_fill_rect(px_of(g_vt.cx), ul_y, cell_w(), scale, f);
}
}
/* Solid block cursor at the current position (g_vt.cx, g_vt.cy). Not
* blinking -- a static indicator, simplest thing that actually shows
* where typed input lands, callable as often as the caller likes since
* it is idempotent (always draws the same thing at the current position).
* A character typed at this position naturally overwrites it --
* draw_cursor_glyph() always fills the whole cell with bg before drawing
* a glyph -- but *leaving* this position without drawing a character
* (Enter/newline) does not, which is what vt100_erase_cursor() below is
* for. No-op outside TTF mode or graphics mode (4.4ab's
* g_terminal_visible), matching every other draw call in this file. */
void vt100_draw_cursor(void)
{
if (!g_terminal_visible || g_glyph_mode != VT_GLYPH_TTF) return;
fb_fill_rect(px_of(g_vt.cx), py_of(g_vt.cy), cell_w(), cell_h(), eff_fg());
}
/* Erases whatever vt100_draw_cursor() last drew at the current position,
* restoring it to plain background -- call this before moving away from
* a cursor-drawn cell without also drawing a character there (the
* newline case in sk_readline: Enter moves to a new line, and nothing
* else would ever overwrite the stray block left behind). */
void vt100_erase_cursor(void)
{
if (!g_terminal_visible || g_glyph_mode != VT_GLYPH_TTF) return;
fb_fill_rect(px_of(g_vt.cx), py_of(g_vt.cy), cell_w(), cell_h(), eff_bg());
}
static void reset_attrs(void)
{
g_vt.fg = g_vt.def_fg;
g_vt.bg = g_vt.def_bg;
g_vt.bold = 0;
g_vt.underline = 0;
g_vt.reverse = 0;
}
static void reset_parser(void)
{
uint32_t i;
g_vt.state = ST_NORMAL;
g_vt.param_count = 0;
g_vt.param_has_value = 0;
for (i = 0; i < VT100_MAX_PARAMS; i++) {
g_vt.params[i] = -1; /* -1 = not set (use default) */
}
}
/* Return parameter N, substituting `def` when not set */
static int32_t param(uint32_t n, int32_t def)
{
if (n >= g_vt.param_count) return def;
return (g_vt.params[n] < 0) ? def : g_vt.params[n];
}
/* -----------------------------------------------------------------------
* Public API
* --------------------------------------------------------------------- */
void vt100_init(void)
{
if (!fb_is_available()) return;
g_vt.cols = fb_width() / cell_w();
g_vt.rows = fb_height() / cell_h();
g_vt.def_fg = VT100_DEFAULT_FG;
g_vt.def_bg = VT100_DEFAULT_BG;
g_vt.cx = g_vt.cy = 0;
g_vt.saved_cx = g_vt.saved_cy = 0;
reset_attrs();
reset_parser();
g_vt.initialized = 1;
erase_display(2);
/* FABRIC.md item 4.4ac: allocate boot-mode scrollback now, at the
* bitmap cell geometry just computed above. Non-fatal on failure, same
* pattern as vt100_enable_ttf()'s TTF allocation below: g_shadow/g_ring
* stay NULL, draw_cursor_glyph()/scroll_up() already gate on that and
* skip scrollback tracking, so boot text just isn't recoverable --
* exactly the pre-4.4ac behavior, not a new failure mode. */
{
uint32_t i;
g_line_stride = g_vt.cols + 1u;
g_ring_cap = VT100_BOOT_SCROLLBACK_MAX_LINES;
g_shadow = (char *)kmalloc((size_t)g_vt.rows * g_line_stride);
g_ring = (char *)kmalloc((size_t)g_ring_cap * g_line_stride);
if (!g_shadow || !g_ring) {
log_message(LOG_ERROR, "vt100: boot scrollback buffer allocation failed, unavailable");
if (g_shadow) { kfree(g_shadow); g_shadow = (void *)0; }
if (g_ring) { kfree(g_ring); g_ring = (void *)0; }
} else {
for (i = 0; i < g_vt.rows; i++) {
uint32_t c;
char *row = g_shadow + (size_t)i * g_line_stride;
for (c = 0; c < g_vt.cols; c++) row[c] = ' ';
row[g_vt.cols] = '\0';
}
}
g_ring_count = 0;
g_ring_head = 0;
}
}
void vt100_enable_ttf(void)
{
if (g_glyph_mode == VT_GLYPH_TTF) return; /* already switched */
if (!g_vt.initialized) return;
if (!g_ttf_ready) {
ttf_raster_cache_slot_t *slots;
int rc;
rc = ttf_load_from_capsule(capsule_get_directory(), capsule_get_descriptors(),
capsule_get_names(), capsule_get_arena(),
VT100_TTF_FONT_CAPSULE, &g_ttf_font);
if (rc != TTF_OK) {
log_message(LOG_ERROR, "vt100: TTF font capsule load failed (rc=%d), staying on font_8x16", rc);
return;
}
slots = (ttf_raster_cache_slot_t *)kmalloc(sizeof(ttf_raster_cache_slot_t) * VT100_TTF_CACHE_SLOTS);
if (!slots) {
log_message(LOG_ERROR, "vt100: TTF cache allocation failed, staying on font_8x16");
return;
}
ttf_raster_cache_init(&g_ttf_cache, slots, VT100_TTF_CACHE_SLOTS);
g_ttf_ready = 1;
}
/* FABRIC.md item 4.4ac: freeze the boot-mode scrollback buffers (if
* any -- vt100_init() may have failed to allocate them) before they
* get overwritten below. g_vt.cols/rows/g_shadow/g_ring/g_line_stride/
* g_ring_count/g_ring_head/g_ring_cap still hold boot-mode values at
* this point, nothing below has touched them yet. g_boot_rows is only
* set non-zero when the snapshot is actually usable -- if boot alloc
* failed, g_shadow is already NULL and g_ring_count is already 0 (
* scroll_up() never touches the ring when g_shadow/g_ring are NULL),
* so leaving g_boot_rows at 0 keeps scrollback_line_at()'s combined
* index space consistent instead of pointing into a NULL buffer. */
g_boot_shadow = g_shadow;
g_boot_ring = g_ring;
g_boot_line_stride = g_line_stride;
g_boot_ring_count = g_ring_count;
g_boot_ring_head = g_ring_head;
g_boot_ring_cap = g_ring_cap;
g_boot_rows = g_shadow ? g_vt.rows : 0;
g_glyph_mode = VT_GLYPH_TTF;
/* Full-screen grid, TTF cell size -- see this file's header comment.
* cols/rows MUST be set before anything below derives stride from
* them via g_line_stride. */
g_vt.cols = fb_width() / cell_w();
g_vt.rows = fb_height() / cell_h();
g_vt.cx = g_vt.cy = 0;
/* One-time full-screen clear at the bitmap-to-TTF transition: the boot/
* POST scroll left the whole framebuffer covered in font_8x16 text.
* erase_display(2) below would cover this on its own now that it's
* always full-screen, but the explicit fill stays as the "clean slate
* before anything else runs" step it always was. */
fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg);
erase_display(2);
/* FABRIC.md item 4.4q: allocate the scrollback shadow + ring buffers
* now that cols/rows are fixed for the rest of this session. Failure
* is non-fatal -- scrollback just stays unavailable (g_shadow/g_ring
* stay NULL, every scrollback call site already checks), REPL text
* output itself is unaffected either way. */
{
uint32_t i;
g_line_stride = g_vt.cols + 1u;
g_ring_cap = VT100_SCROLLBACK_MAX_LINES;
g_shadow = (char *)kmalloc((size_t)g_vt.rows * g_line_stride);
g_ring = (char *)kmalloc((size_t)g_ring_cap * g_line_stride);
if (!g_shadow || !g_ring) {
log_message(LOG_ERROR, "vt100: scrollback buffer allocation failed, scrollback unavailable");
if (g_shadow) { kfree(g_shadow); g_shadow = (void *)0; }
if (g_ring) { kfree(g_ring); g_ring = (void *)0; }
} else {
for (i = 0; i < g_vt.rows; i++) {
uint32_t c;
char *row = g_shadow + (size_t)i * g_line_stride;
for (c = 0; c < g_vt.cols; c++) row[c] = ' ';
row[g_vt.cols] = '\0';
}
}
g_ring_count = 0;
g_ring_head = 0;
g_scroll_offset = 0;
}
}
/* Fetch the text of combined-history index `idx` (0 = oldest boot-ring
* entry, ... newest TTF-shadow row = live) -- four segments back to back:
* boot ring, boot shadow (frozen at the boot->TTF transition), TTF ring,
* TTF shadow (live). See 4.4ac's block above for why boot and TTF each
* keep their own ring/shadow pair (different cell geometry, different
* g_line_stride) instead of one reflowed buffer, and 4.4q's block for the
* ring-then-shadow model each pair follows individually. Only ever called
* once TTF mode is active (scrollback is wired to a keyboard command that
* doesn't exist before the REPL starts -- console.c's
* console_fb_scroll_back()/_fwd()), so g_ring/g_shadow here always mean
* the TTF pair, never the boot one. */
static const char *scrollback_line_at(uint32_t idx)
{
if (idx < g_boot_ring_count) {
uint32_t slot = (g_boot_ring_head + idx) % g_boot_ring_cap;
return g_boot_ring + (size_t)slot * g_boot_line_stride;
}
idx -= g_boot_ring_count;
if (idx < g_boot_rows) {
return g_boot_shadow + (size_t)idx * g_boot_line_stride;
}
idx -= g_boot_rows;
if (idx < g_ring_count) {
uint32_t slot = (g_ring_head + idx) % g_ring_cap;
return g_ring + (size_t)slot * g_line_stride;
} else {
uint32_t row = idx - g_ring_count;
if (row >= g_vt.rows) row = g_vt.rows - 1; /* defensive clamp */
return g_shadow + (size_t)row * g_line_stride;
}
}
/* Redraw every row from the combined history at the current
* g_scroll_offset. Uses the terminal's current default colors, not each
* line's original SGR state -- see this file's 4.4q block. `total` now
* spans all four 4.4ac segments, not just the TTF ring + shadow, so
* scrolling back from the REPL reaches all the way through POST. */
static void scrollback_redraw(void)
{
uint32_t total = g_boot_ring_count + g_boot_rows + g_ring_count + g_vt.rows;
uint32_t row;
for (row = 0; row < g_vt.rows; row++) {
uint32_t idx = total - g_vt.rows - g_scroll_offset + row;
const char *text = scrollback_line_at(idx);
uint32_t col;
for (col = 0; col < g_vt.cols; col++) {
uint8_t ch = (uint8_t)(text[col] ? text[col] : ' ');
if (g_glyph_mode == VT_GLYPH_TTF) {
ttf_draw_glyph_cell(px_of(col), py_of(row), ch, g_vt.def_fg, g_vt.def_bg);
} else {
fb_draw_glyph(px_of(col), py_of(row), ch, g_vt.def_fg, g_vt.def_bg);
}
}
}
}
/* FABRIC.md item 4.4y-revised: Alt+TAB graphics/text toggle, a two-state
* machine (VISIBLE <-> HIDDEN) with exactly one transition function --
* both the physical Alt+TAB interception (repl.c) and the ALT+TAB FORTH
* word (keyboard_words.c) call this same function, so there is exactly
* one place the state actually flips, not two call sites each doing it
* independently.
*
* VISIBLE -> HIDDEN: flip the flag only. draw_cursor_glyph()/
* erase_line_range()/erase_display()/scroll_up() all check
* g_terminal_visible and skip their framebuffer writes from this point
* on, while still advancing g_vt's cursor/attributes and the g_shadow/
* g_ring scrollback state normally -- graphics-mode drawing (direct
* framebuffer or TTF-TEXT calls) is then free to use the whole screen,
* and the terminal's logical state stays perfectly consistent with what
* it would have been had it stayed visible.
*
* HIDDEN -> VISIBLE: flip the flag, then wipe whatever graphics drew and
* reuse scrollback_redraw() to repaint the terminal's current on-screen
* content from that untouched logical state -- restored exactly as it
* logically stood, not merely as it last looked on screen. */
void vt100_toggle_graphics(void)
{
if (!g_ttf_ready || g_glyph_mode != VT_GLYPH_TTF) return;
if (g_terminal_visible) {
g_terminal_visible = 0;
return;
}
g_terminal_visible = 1;
fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg);
scrollback_redraw();
}
void vt100_scroll_back(uint32_t n)
{
uint32_t history; /* 4.4ac: boot ring + boot shadow + TTF ring, not just the TTF ring */
if (!g_shadow || !g_ring) return;
history = g_boot_ring_count + g_boot_rows + g_ring_count;
if (n > history - g_scroll_offset) n = history - g_scroll_offset;
if (n == 0) return;
g_scroll_offset += n;
scrollback_redraw();
}
void vt100_scroll_fwd(uint32_t n)
{
if (!g_shadow || !g_ring) return;
if (n > g_scroll_offset) n = g_scroll_offset;
if (n == 0) return;
g_scroll_offset -= n;
scrollback_redraw();
}
uint32_t vt100_cols(void) { return g_vt.cols; }
uint32_t vt100_rows(void) { return g_vt.rows; }
/* -----------------------------------------------------------------------
* Cursor movement
* --------------------------------------------------------------------- */
static void set_cursor(uint32_t row, uint32_t col)
{
if (row >= g_vt.rows) row = g_vt.rows - 1;
if (col >= g_vt.cols) col = g_vt.cols - 1;
g_vt.cy = row;
g_vt.cx = col;
}
static void advance_cursor(void)
{
g_vt.cx++;
if (g_vt.cx >= g_vt.cols) {
g_vt.cx = 0;
g_vt.cy++;
if (g_vt.cy >= g_vt.rows) {
scroll_up(1);
g_vt.cy = g_vt.rows - 1;
}
}
}
/* FABRIC.md item 4.4q: push the rows about to be discarded into the
* scrollback ring before the pixel scroll happens, then shift the shadow
* buffer to match. `n` is clamped to g_vt.rows -- scrolling further than
* a full screen at once cannot happen through this code path (advance_cursor
* only ever calls scroll_up(1)), but the clamp keeps this correct even if
* a future caller passes more. */
static void scroll_up(uint32_t lines)
{
if (g_shadow && g_ring && lines > 0) {
uint32_t n = lines > g_vt.rows ? g_vt.rows : lines;
uint32_t i, r;
for (r = 0; r < n; r++) {
uint32_t slot = (g_ring_head + g_ring_count) % g_ring_cap;
char *dst = g_ring + (size_t)slot * g_line_stride;
char *src = g_shadow + (size_t)r * g_line_stride;
for (i = 0; i < g_line_stride; i++) dst[i] = src[i];
if (g_ring_count < g_ring_cap) {
g_ring_count++;
} else {
g_ring_head = (g_ring_head + 1) % g_ring_cap;
}
}
for (r = 0; r + n < g_vt.rows; r++) {
char *dst = g_shadow + (size_t)r * g_line_stride;
char *src = g_shadow + (size_t)(r + n) * g_line_stride;
for (i = 0; i < g_line_stride; i++) dst[i] = src[i];
}
for (r = g_vt.rows - n; r < g_vt.rows; r++) {
char *row = g_shadow + (size_t)r * g_line_stride;
for (i = 0; i < g_vt.cols; i++) row[i] = ' ';
row[g_vt.cols] = '\0';
}
}
/* Full-framebuffer scroll -- both glyph modes cover the whole screen
* now, so there is no box to scope this to. */
if (g_terminal_visible) {
fb_scroll_rows(lines, g_vt.def_bg);
}
}
/* -----------------------------------------------------------------------
* Erase operations
* --------------------------------------------------------------------- */
/* FABRIC.md item 4.4i found this drawing a per-cell blank glyph
* (font_8x16.c-specific, and inconsistent with erase_display(2)'s
* full-screen case just below, which already used a plain rect fill).
* 4.4j fixes both problems at once: a blank cell is visually identical to
* a filled bg rect regardless of glyph backend, so this needs no font
* involvement at all -- one fb_fill_rect() for the whole range instead of
* a per-column glyph draw. */
static void erase_line_range(uint32_t row, uint32_t c0, uint32_t c1)
{
if (c1 <= c0) return;
if (!g_terminal_visible) return; /* 4.4y-revised: graphics mode */
fb_fill_rect(px_of(c0), py_of(row), (c1 - c0) * cell_w(), cell_h(), g_vt.bg);
}
static void erase_display(int mode)
{
if (mode == 0) {
/* erase from cursor to end */
erase_line_range(g_vt.cy, g_vt.cx, g_vt.cols);
uint32_t r;
for (r = g_vt.cy + 1; r < g_vt.rows; r++) {
erase_line_range(r, 0, g_vt.cols);
}
} else if (mode == 1) {
/* erase from start to cursor */
uint32_t r;
for (r = 0; r < g_vt.cy; r++) {
erase_line_range(r, 0, g_vt.cols);
}
erase_line_range(g_vt.cy, 0, g_vt.cx + 1);
} else {
/* erase entire screen -- full framebuffer, both glyph modes. */
if (g_terminal_visible) {
fb_fill_rect(0, 0, fb_width(), fb_height(), g_vt.def_bg);
}
g_vt.cx = g_vt.cy = 0;
}
}
static void erase_line(int mode)
{
if (mode == 0) {
erase_line_range(g_vt.cy, g_vt.cx, g_vt.cols);
} else if (mode == 1) {
erase_line_range(g_vt.cy, 0, g_vt.cx + 1);
} else {
erase_line_range(g_vt.cy, 0, g_vt.cols);
}
}
/* -----------------------------------------------------------------------
* SGR (Select Graphic Rendition) handler
*
* The parameter list may contain sub-sequences like:
* 38 ; 2 ; r ; g ; b truecolor fg
* 38 ; 5 ; n 256-color fg
* 48 ; 2 ; r ; g ; b truecolor bg
* 48 ; 5 ; n 256-color bg
* These are walked in a sub-loop.
* --------------------------------------------------------------------- */
/* 6×6×6 colour cube + grayscale ramp for 256-color mode */
static uint32_t ansi256_to_rgb(uint32_t idx)
{
if (idx < 16u) {
return FB_ANSI_PALETTE[idx];
}
if (idx >= 232u) {
uint32_t v = 8u + (idx - 232u) * 10u;
return FB_RGB(v, v, v);
}
/* 6×6×6 colour cube: indices 16–231 */
idx -= 16u;
uint32_t r6 = idx / 36u;
uint32_t g6 = (idx % 36u) / 6u;
uint32_t b6 = idx % 6u;
uint32_t r = r6 ? (55u + r6 * 40u) : 0u;
uint32_t g = g6 ? (55u + g6 * 40u) : 0u;
uint32_t b = b6 ? (55u + b6 * 40u) : 0u;
return FB_RGB(r, g, b);
}
static void apply_sgr(void)
{
uint32_t i = 0;
if (g_vt.param_count == 0) {
reset_attrs();
return;
}
while (i < g_vt.param_count) {
int32_t p = param(i, 0);
if (p == 0) {
reset_attrs();
} else if (p == 1) {
g_vt.bold = 1;
} else if (p == 4) {
g_vt.underline = 1;
} else if (p == 7) {
g_vt.reverse = 1;
} else if (p == 21 || p == 22) {
g_vt.bold = 0;
} else if (p == 24) {
g_vt.underline = 0;
} else if (p == 27) {
g_vt.reverse = 0;
} else if (p >= 30 && p <= 37) {
g_vt.fg = FB_ANSI_PALETTE[p - 30];
} else if (p == 38) {
int32_t mode = param(i + 1, -1);
if (mode == 2 && i + 4 < g_vt.param_count) {
uint32_t r = (uint32_t)param(i + 2, 0);
uint32_t g = (uint32_t)param(i + 3, 0);
uint32_t b = (uint32_t)param(i + 4, 0);
g_vt.fg = FB_RGB(r, g, b);
i += 4;
} else if (mode == 5 && i + 2 < g_vt.param_count) {
g_vt.fg = ansi256_to_rgb((uint32_t)param(i + 2, 0));
i += 2;
}
} else if (p == 39) {
g_vt.fg = g_vt.def_fg;
} else if (p >= 40 && p <= 47) {
g_vt.bg = FB_ANSI_PALETTE[p - 40];
} else if (p == 48) {
int32_t mode = param(i + 1, -1);
if (mode == 2 && i + 4 < g_vt.param_count) {
uint32_t r = (uint32_t)param(i + 2, 0);
uint32_t g = (uint32_t)param(i + 3, 0);
uint32_t b = (uint32_t)param(i + 4, 0);
g_vt.bg = FB_RGB(r, g, b);
i += 4;
} else if (mode == 5 && i + 2 < g_vt.param_count) {
g_vt.bg = ansi256_to_rgb((uint32_t)param(i + 2, 0));
i += 2;
}
} else if (p == 49) {
g_vt.bg = g_vt.def_bg;
} else if (p >= 90 && p <= 97) {
g_vt.fg = FB_ANSI_PALETTE[p - 90 + 8];
} else if (p >= 100 && p <= 107) {
g_vt.bg = FB_ANSI_PALETTE[p - 100 + 8];
}
i++;
}
}
/* -----------------------------------------------------------------------
* CSI dispatch
* --------------------------------------------------------------------- */
static void dispatch_csi(char final)
{
int32_t p0 = param(0, -1);
int32_t p1 = param(1, -1);
int32_t n;
switch (final) {
case 'A': /* CUU cursor up */
n = (p0 < 1) ? 1 : p0;
g_vt.cy = (g_vt.cy >= (uint32_t)n) ? g_vt.cy - (uint32_t)n : 0;
break;
case 'B': /* CUD cursor down */
n = (p0 < 1) ? 1 : p0;
g_vt.cy += (uint32_t)n;
if (g_vt.cy >= g_vt.rows) g_vt.cy = g_vt.rows - 1;
break;
case 'C': /* CUF cursor forward */
n = (p0 < 1) ? 1 : p0;
g_vt.cx += (uint32_t)n;
if (g_vt.cx >= g_vt.cols) g_vt.cx = g_vt.cols - 1;
break;
case 'D': /* CUB cursor back */
n = (p0 < 1) ? 1 : p0;
g_vt.cx = (g_vt.cx >= (uint32_t)n) ? g_vt.cx - (uint32_t)n : 0;
break;
case 'H': /* CUP cursor position ESC[row;colH (1-based) */
case 'f':
{
uint32_t row = (p0 <= 0) ? 0 : (uint32_t)(p0 - 1);
uint32_t col = (p1 <= 0) ? 0 : (uint32_t)(p1 - 1);
set_cursor(row, col);
}
break;
case 'J': /* ED erase in display */
erase_display((p0 < 0) ? 0 : p0);
break;
case 'K': /* EL erase in line */
erase_line((p0 < 0) ? 0 : p0);
break;
case 'm': /* SGR */
apply_sgr();
break;
case 's': /* SCP save cursor */
g_vt.saved_cx = g_vt.cx;
g_vt.saved_cy = g_vt.cy;
break;
case 'u': /* RCP restore cursor */
set_cursor(g_vt.saved_cy, g_vt.saved_cx);
break;
/* unrecognised sequences are silently ignored */
default:
break;
}
}
/* -----------------------------------------------------------------------
* Printable character output
* --------------------------------------------------------------------- */
static void put_char(uint8_t ch)
{
draw_cursor_glyph(ch);
advance_cursor();
}
/* -----------------------------------------------------------------------
* vt100_putc — the main entry point
* --------------------------------------------------------------------- */
void vt100_putc(char raw)
{
uint8_t c = (uint8_t)raw;
if (!g_vt.initialized) return;
switch (g_vt.state) {
/* ------------------------------------------------------------------ */
case ST_NORMAL:
if (c == 0x1B) {
g_vt.state = ST_ESC;
} else if (c == '\r') {
g_vt.cx = 0;
} else if (c == '\n') {
g_vt.cy++;
if (g_vt.cy >= g_vt.rows) {
scroll_up(1);
g_vt.cy = g_vt.rows - 1;
}
} else if (c == '\t') {
/* advance to next 8-column tab stop */
g_vt.cx = (g_vt.cx + 8u) & ~7u;
if (g_vt.cx >= g_vt.cols) {
g_vt.cx = 0;
g_vt.cy++;
if (g_vt.cy >= g_vt.rows) {
scroll_up(1);
g_vt.cy = g_vt.rows - 1;
}
}
} else if (c == '\b') {
if (g_vt.cx > 0) g_vt.cx--;
} else if (c == 0x07) {
/* BEL — ignored */
} else if (c >= 0x20u) {
put_char(c);
}
break;
/* ------------------------------------------------------------------ */
case ST_ESC:
if (c == '[') {
reset_parser();
g_vt.state = ST_CSI;
} else if (c == '7') {
g_vt.saved_cx = g_vt.cx;
g_vt.saved_cy = g_vt.cy;
g_vt.state = ST_NORMAL;
} else if (c == '8') {
set_cursor(g_vt.saved_cy, g_vt.saved_cx);
g_vt.state = ST_NORMAL;
} else if (c == 'M') {
/* RI reverse index: scroll down (insert line at top) */
if (g_vt.cy == 0) {
/* Scroll display down: not trivially done without a
* full back-buffer; fill top row with bg instead. */
fb_fill_rect(0, 0, fb_width(), cell_h(), g_vt.def_bg);
} else {
g_vt.cy--;
}
g_vt.state = ST_NORMAL;
} else if (c == 'c') {
/* RIS full reset */
reset_attrs();
g_vt.cx = g_vt.cy = 0;
erase_display(2);
g_vt.state = ST_NORMAL;
} else if (c == '#') {
g_vt.state = ST_ESC_HASH;
} else if (c == '(' || c == ')') {
g_vt.state = ST_ESC_CHAR;
} else {
/* Unknown 2-char ESC sequence — absorb and reset */
g_vt.state = ST_NORMAL;
}
break;
/* ------------------------------------------------------------------ */
case ST_ESC_HASH:
case ST_ESC_CHAR:
/* Eat the designator byte and return to normal */
g_vt.state = ST_NORMAL;
break;
/* ------------------------------------------------------------------ */
case ST_CSI:
if (c >= '0' && c <= '9') {
uint32_t i = g_vt.param_count;
if (i < VT100_MAX_PARAMS) {
if (g_vt.params[i] < 0) g_vt.params[i] = 0;
g_vt.params[i] = g_vt.params[i] * 10 + (int32_t)(c - '0');
g_vt.param_has_value = 1;
}
} else if (c == ';') {
if (g_vt.param_count < VT100_MAX_PARAMS - 1u) {
g_vt.param_count++;
}
g_vt.param_has_value = 0;
} else if (c == '?' || c == '>' || c == '<' || c == '!') {
/* Private / intermediate bytes — swallow, keep parsing */
} else if (c >= 0x40 && c <= 0x7E) {
/* Final byte — dispatch and return to normal */
if (g_vt.param_has_value || g_vt.param_count > 0) {
g_vt.param_count++;
}
dispatch_csi((char)c);
g_vt.state = ST_NORMAL;
} else {
/* Unexpected — abort */
g_vt.state = ST_NORMAL;
}
break;
}
}
void vt100_puts(const char *s)
{
if (!s) return;
while (*s) {
vt100_putc(*s++);
}
}