starkernel: REPL scrollback, ~1000 lines (FABRIC.md item 4.4q)

Scope decided with Captain Bob before implementation: ring buffer +
recall on today's full-screen vt100 grid, not also confining REPL text
to the 4.4o 640x480 box (that confinement stays open as its own future
item, not a third silent deferral). No keyboard input path exists yet
(M8 unstarted), so the trigger is two new FORTH words, SCROLL-BACK
( n -- ) / SCROLL-FWD ( n -- ), exercised via serial injection.

vt100.c gains a text-only 1000-line ring buffer (kmalloc'd, tens of KB
-- not pixel snapshots, which would be ~1000x larger for no benefit)
plus a shadow buffer mirroring the current screen. scroll_up() now
pushes evicted rows into the ring before the pixel scroll. History is
one continuous sequence (ring then shadow); scrolling always redraws
from that sequence -- no separate pixel-scroll path for scrollback,
decided up front to avoid retrofitting later.

New src/word_source/scroll_words.c (Module 31), thin wrappers over
console_fb_scroll_back()/_fwd() -> vt100_scroll_back()/_fwd(). Bug
caught during live testing: both words initially used an off-by-one
underflow check (dsp < 1) copied from a different, older dsp
convention elsewhere in this codebase; vm_pop() (which these words
actually call) uses dsp as a 0-based top-of-stack index, so the check
rejected every legitimate single-argument call. Fixed by removing the
separate precheck and relying on vm_pop()'s own guard.

Live-verified on all three architectures (exceeds this item's
amd64-minimum bar): generated 50+ lines via a FORTH loop, confirmed
SCROLL-BACK recovers correctly older content, and on amd64 confirmed
SCROLL-FWD returns to genuinely live state (not a frozen snapshot) by
showing the injected commands' own echo. Known limitation confirmed by
direct pixel measurement: redrawn lines lose their original SGR color
(not stored per-cell) -- text recovers exactly, color does not.

Three-arch verified: Failed: 0, dict-hashes identical across all
three (values changed correctly from prior items -- two new words
were added).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-08-11 21:23:25 -04:00
co-authored by Claude Sonnet 5
parent 277845e3bf
commit 1f3ec3554e
27 changed files with 73487 additions and 112 deletions
+14
View File
@@ -303,6 +303,20 @@ void console_fb_enable_ttf(void)
}
}
void console_fb_scroll_back(uint32_t n)
{
if (fb_is_available()) {
vt100_scroll_back(n);
}
}
void console_fb_scroll_fwd(uint32_t n)
{
if (fb_is_available()) {
vt100_scroll_fwd(n);
}
}
/**
* Read a single character from serial console (non-blocking)
* Returns -1 if no character available
+178 -9
View File
@@ -57,13 +57,12 @@
* FABRIC.md item 4.4j: TTF glyph backend, active only from
* vt100_enable_ttf() onward (boot/POST stays font_8x16.c).
*
* Provisional point size and cell geometry -- item 4.4m owns the final
* REPL-strip decision. Chosen so 4.4i's confirmed hmtx result (all
* 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) -- provisional like the
* width.
* 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 */
@@ -81,6 +80,52 @@ 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.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; /* VT100_SCROLLBACK_MAX_LINES * g_line_stride bytes */
static uint32_t g_line_stride = 0; /* cols + 1 (NUL terminator), fixed once TTF mode starts */
static uint32_t g_ring_count = 0; /* valid stored lines, <= VT100_SCROLLBACK_MAX_LINES */
static uint32_t g_ring_head = 0; /* ring slot index of the oldest stored line */
static uint32_t g_scroll_offset = 0; /* 0 = live view */
/* -----------------------------------------------------------------------
* Parser state enum
* --------------------------------------------------------------------- */
@@ -204,6 +249,12 @@ 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);
@@ -311,15 +362,99 @@ void vt100_enable_ttf(void)
g_glyph_mode = VT_GLYPH_TTF;
/* Cell geometry just changed (font_8x16's fixed 8x16 vs. TTF's
* provisional 12x24, see this file's 4.4j block above) -- re-derive
* cols/rows and clear, the same as vt100_init() itself does. This only
* ever runs once, at the POST-to-REPL boundary (sk_repl()), so nothing
* of value is on screen yet to preserve. */
/* Cell geometry just changed (font_8x16's fixed 8x16 vs. TTF's 12x24,
* see this file's 4.4j block above) -- re-derive cols/rows and clear,
* the same as vt100_init() itself does. This only ever runs once, at
* the POST-to-REPL boundary (sk_repl()), so nothing of value is on
* screen yet to preserve. */
g_vt.cols = fb_width() / cell_w();
g_vt.rows = fb_height() / cell_h();
g_vt.cx = g_vt.cy = 0;
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_shadow = (char *)kmalloc((size_t)g_vt.rows * g_line_stride);
g_ring = (char *)kmalloc((size_t)VT100_SCROLLBACK_MAX_LINES * 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 ring entry,
* g_ring_count + g_vt.rows - 1 = newest shadow row) -- see this file's
* 4.4q block for the ring-then-shadow history model. */
static const char *scrollback_line_at(uint32_t idx)
{
if (idx < g_ring_count) {
uint32_t slot = (g_ring_head + idx) % VT100_SCROLLBACK_MAX_LINES;
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. */
static void scrollback_redraw(void)
{
uint32_t total = 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);
}
}
}
}
void vt100_scroll_back(uint32_t n)
{
if (!g_shadow || !g_ring) return;
if (n > g_ring_count - g_scroll_offset) n = g_ring_count - 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; }
@@ -350,8 +485,42 @@ static void advance_cursor(void)
}
}
/* 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) % VT100_SCROLLBACK_MAX_LINES;
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 < VT100_SCROLLBACK_MAX_LINES) {
g_ring_count++;
} else {
g_ring_head = (g_ring_head + 1) % VT100_SCROLLBACK_MAX_LINES;
}
}
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';
}
}
fb_scroll_rows(lines, g_vt.def_bg);
}