# FABRIC.md — the Stadium **Status: CLOSED, 2026-08-12.** This document is archival. It is not edited further and no new work is added to it — everything still open as of the closure date was carried forward into **`FABRIC-2.md`**, which is now the living document. Read `FABRIC-2.md` first for current status; come back here only for the historical design argument and decision record behind it. **Why it's closed, not just superseded.** §1–24 (the theory) and §25 (the punch list) did their job — the design argument is settled, and every implementation item through 4.5 and 4.4ac either landed or was explicitly deferred with a reason. Closing a finished document rather than continuing to append to a 7,500+-line file keeps the still-open work findable; the alternative was letting it stay buried among hundreds of resolved items. **The 2026-08-12 closure audit.** Before closing, the entire document was read end to end (not sampled) looking for anything not yet resolved — punch-list checkboxes, the nine "### N.N Open" architectural subsections in §1–24, the §25.7 "reported, not scheduled" gap list, and any other "not yet"/"deferred"/"still open" language anywhere in the text. Four places were found where something had actually been resolved by later work but the document's own note was never updated (§19.6 #3, §21.5 #4, the §25.7 `stadium_owner[idx]` bullet, and item 4.5's own parent checkbox) — those four are fixed in place below, since they were bookkeeping errors, not open work. Everything else genuinely still open is in `FABRIC-2.md`. **How to read what remains.** §1–15 are the original design argument, written before any code was examined. §16 onward are findings and decisions made against the actual tree, in the order they happened. **Where the two disagree, the later section wins** — earlier text is left standing, with a pointer, because §19.4 and §19.5 quote it directly and because retracing the reasoning matters more than a tidy read. **On the name.** The thing described here is the **Stadium**. "The arena" was the working name until it collided with `src/starkernel/vm/arena.c` — the PMM-backed VM page allocator, an unrelated structure. The document has been swept; "arena" now survives only inside block quotes that reproduce an earlier section verbatim, and in §12's preserved question list, which several sections quote. **On `docs/lithosananke/ROADMAP.md`.** As of 2026-08-11, Captain Bob stated this document superseded ROADMAP.md entirely. That still holds, transitively, through `FABRIC-2.md` now. --- ## 1. The claim StarshipOS currently has four subsystems that each independently implement the same physics: Artemis heats blocks, Hermes ages messages, Console heats dirty cells, ACLs carry heat and TTL. Four implementations, one pattern. The claim is that this is one mechanism wearing four costumes, and that the dictionary is already the reference implementation of it. Lift the dictionary one level of abstraction and every subsystem becomes an instance rather than a special case. The argument that decides it: **they already have the same wires.** Blocks felt different because they are large and live on disk — but size and location are not properties, they are payload details. Strip those away and a block has exactly what a message has. **DECIDED.** Direction is not optional. The remaining question is effort, not validity. > **Updated by §20 and §17.5.** There are five patron kinds, not four — VMs are the fifth > and were already implemented (§20.1). And the Console's patron is the dirty *event*, not > the cell (§17.5); "Console heats dirty cells" above is the reading that §9 flagged as > suspect and §17.5 resolved. --- ## 2. The Stadium A single region of memory, outside any VM, holding everything currently **live**. - Bounded capacity. The bound is real and inescapable. - Allocated at boot, before any VM exists. - Not part of the heap. > **Corrected after §19.1.** This bullet originally read *"the bound is what gives K≡1.0 a > fixed denominator. Without a hard outer wall, K is bookkeeping rather than a conservation > law."* That justification does not survive the definition of K. > > §19.1 establishes K as a conserved, normalised **heat share** summing to 1.0. Its > denominator is 1.0 by definition; capacity does not enter it, and §19.2 says outright that > mass never enters K. A transfer-based sum is equally conserved at three patrons or three > hundred — population is not what makes the check meaningful. > > The bound is still necessary, for two reasons this section can honestly claim: > > - **Finite state (§13).** A bounded population is what makes induction over the Stadium > straightforward and puts model checking alongside theorem proving. This is the larger > payoff and it does depend on the wall. > - **Density needs a volume.** §19.2 defines density as heat ÷ mass, and mass is cells > occupied. Without a fixed capacity there is nothing for a patron to be dense *within*, > and §19.3's admission rule — admit if denser than the least dense resident — has no > meaning because nothing is ever full. > > What makes conservation falsifiable is the *mechanism*, not the bound: heat that is > **transferred** can drift and be caught; heat that is **renormalised** cannot. See §20.2. The critical scoping decision, and the one that keeps this from sprawling: > **The Stadium holds what is live. Not everything that exists.** **DECIDED.** --- ## 3. The entry One structure. No variants, no type field, no subclassing. | Wire | Meaning | |---|---| | identity | handle or name — **never a content hash while resident** (§24.4) | | heat | conserved share of 1.0, moved by traffic (§19.1) | | TTL | remaining lifetime — messages and ACLs only (§17.1) | | pin | invariance flag (opposite of TTL, not an extension of it) | | link | index into the Stadium, not a pointer | | code field | behaviour tag from a closed enumeration (§18.3) | | **mass** | **cells this patron occupies — its footprint (§19.2)** | | payload | carried in the patron's own cells; large patrons are simply heavy (§23.1) | | **contains** | **index of the patron currently held inside this one, or none — containment, not a lock (§8, item 1.1)** | Fixed-size cells. Links are indices, so the Stadium stays an array — no fragmentation, and tractable for Isabelle later. #### A cell is one of exactly two things The wire table above describes a **patron header**. §23.1 establishes that a large patron is not held by reference but simply occupies more cells — a 1024-byte block is 17 cells, one header and sixteen of payload. Those sixteen carry no identity, no heat, no TTL and no code field. That is a second cell shape, and this section's opening line — *"One structure. No variants"* — forbade it without saying so. Declared properly: > **A cell is either a patron header or a continuation cell owned by exactly one patron. > The union is closed, two-valued, and fixed at build time.** This introduces no new principle. It is the same discipline §18.3 applies to behaviours: a closed enumeration fixed at build time is as tractable in HOL as a single record, and a two-valued union is the smallest possible instance of one. §13's "one datatype" remains true in substance — the datatype is now a two-constructor sum rather than a single record, which costs a case split and nothing else. What it is **not** is a type field. The engine does not ask a header what kind of patron it is; the two-valued distinction is structural, tells the engine only whether a cell begins a patron or continues one, and is exhausted by that. A continuation cell is never ranked, never reaped and never dispatched — it is floor space, accounted for in its owner's mass. > **Amended by §19.2 and §23.1.** `mass` is an eighth wire, added when density was > defined — density is heat ÷ mass, so mass has to live in the entry. And the original > payload rule ("inline if small, by reference if large") was dissolved rather than > answered: a large patron occupies more cells, which is what mass already measures. > By-reference is reserved for things outside the Stadium, which are not patrons. > > **Amended again by item 1.1 (§25.2).** `contains` is a ninth wire — an index to the > patron currently held inside this one, or none. This does not reopen the two-valued cell > union above: the contained patron keeps its own independent header and cells elsewhere in > the Stadium, resolved and ranked exactly as any other patron. `contains` is a reference to > that residency, not a physical embedding of one patron's cells inside another's. See §8 for > what this wire is for and why it replaced a lock. > > **Amended by item 3.1 (2026-08-04) — the header/continuation discriminator is an > external side bitmap, not a header field.** A flat-array scan must tell a header cell > from a continuation cell before it knows which shape it is looking at. That rules out > folding a tag into either variant's own bytes: a continuation cell has no header fields > to place one in, and forcing a common leading tag byte into both shapes would eat into > the continuation cell's usable payload, contradicting §23.3's 60-byte figure. Ruled: one > bit per cell, in a bitmap kept outside the 64-byte cell array. Item 3.1 declares the > bitmap's purpose and indexing; item 3.2 (boot-time allocation) allocates it, since it is > Stadium-sized memory decided at boot alongside the cell array itself. **The code field is the entire type system.** A block's code field migrates. A message's delivers. A VM's ticks. The engine never asks what kind of thing it is holding; it heats, ranks, reaps, and calls the code field. > If you find yourself wanting a type field so the engine can branch on entry kind, the > design has gone wrong. The code field already answers that question. **DECIDED**, including the payload question — dissolved in §23.1. --- ## 4. Heat Heat is **conferred by traffic, not intrinsic to the entry.** This is the piece that was missing for most of the session. Nothing decides what matters. An entry is hot because activity is concentrated around it — the way a crowd in front of one car makes that corner of the hall hot. Density generates heat; nobody computes it. Consequences: - **Ranking is read, not decided.** There is no scheduler because there is no policy. The Stadium is simply already in heat order when you look at it. - **K constrains the total,** so ordering is forced by conservation rather than by tuned parameters. There is nothing to tune wrongly. This is the defensible distinction from a scheduler and it belongs in the write-up. - **Popularity is self-limiting.** A crowded entry is harder to reach, which throttles traffic to it, which cools it. The governor is local and emergent — no global damping constant to pick. TTL expiry stays unconditional: entries leave at their own time, unscheduled, nobody's decision. Pinning remains the separate, opposite mechanism — invariance, not longevity. **DECIDED as amended by §19.** The density formulation this section called for is supplied there. Three specific amendments, and the original wording above is left intact because §19.4 and §19.5 quote it: - **"Density generates heat" is backwards** (§19.5). Traffic confers heat; density is heat ÷ mass, derived downstream. One word was carrying two meanings. - **The third bullet is struck, not repaired** (§19.4). "A crowded entry is harder to reach" does not translate — a hot entry is *easier* to reach, which is what a cache is for. The conclusion survives via the second bullet: heat is zero-sum, so popularity is self-limiting by conservation. - **The first bullet is now true rather than aspirational.** Ranking reads density, which §19.2 makes a number. The line about TTL and pinning is correct but incomplete — §17.1 shows there are three departure mechanisms, not two: TTL, heat decay, and pin. --- ## 5. What is *not* in the Stadium This section exists because forcing everything in is how this design turns into a mess. - **Storage is beneath the Stadium.** The show floor is not the warehouse. Artemis is where entries live when they are not in play. Blocks migrate onto the floor when hot and back out when cold — which is heat-driven block migration, already built. Artemis does not become a Stadium occupant; it becomes what the Stadium pages against. - **Devices are beside the Stadium.** The framebuffer is the building's lighting, not an occupant. Console's dirty *events* are Stadium entries; the pixels are not. **DECIDED**, and completed by §17.5, which supplies the third edge this section counted but did not list, and sharpens the second: | Category | Relation | Example | |---|---|---| | Warehouse | beneath | Artemis, and the dictionary (§17.3) | | Stadium | the floor | patrons | | Utility | beside | framebuffer, and devices generally | "The building's lighting" undersells the framebuffer — it reads as part of the structure. §17.5 calls it **the power company**: external infrastructure the building consumes. Not the Stadium, not the basement of the Stadium, a third thing. --- ## 6. Boot order The engine cannot be a VM service, because VMs live inside the thing it manages. 1. LithosAnanke establishes the Stadium and starts the engine. 2. Hera becomes the first entry in it. 3. Hera births everything else, sizing each VM as it goes. Structurally the same move as minting Zuse's certificate at first boot: a root that cannot be produced by the mechanism it grounds. **DECIDED.** The order was right, and the allocation mechanism this section left unspecified is now given: one global array of fixed-size cells, sized at boot from the memory budget, addressed by index (§17.6b, §22.3). Step 1 above allocates that array before any VM exists; step 2 makes Hera the first patron in it (§20). --- ## 7. Hera Hera's job becomes Stadium distribution. This is not a new responsibility — allocating a VM's share *is* birthing it, and lifecycle is already what Hera is for. ~~**OPEN:**~~ **RESOLVED in §22 — elastic.** Whether a VM's share is a hard bound or an elastic one that can grow and shrink under pressure, with capacity transferring between VMs as a conserved operation Hera arbitrates. Elastic is more powerful and more work. Under elasticity, birth sizes the *rest* volume rather than a cap — a more forgiving thing to have to guess right. §22 takes the elastic option. §19's density definition turns it into a negative feedback loop that runs itself — capacity flows down the density gradient — so it costs less than this section anticipated. The layout that makes it cheap is a single global cell pool with per-VM quotas held as counts (§22.3), and capacity must move on a slower loop than heat (§22.4). --- ## 8. The mental model An auto show hall. Cars and people, in a building with a fixed capacity. People arrive and leave at their own times. They ask questions and converse — those are the messages. They stand in front of a car for a while and move on. Occasionally one sits in a car, which is the only exclusive thing in the room, scoped to a single object, no global lock. The hall gets crowded. Crowds get hot. **One discipline to hold:** cars and people cannot be two structures. That would be a type field re-entering through a metaphor. They are one entry shape differing only in TTL and code field — a car's lifetime is the show, a person's is a visit; a car's code field is *be attended to*, a person's is *move and attend*. > **RESOLVED by item 1.1 (§25.2), 2026-08-04.** *"Occasionally one sits in a car, which is > the only exclusive thing in the room, scoped to a single object, no global lock."* The > instinct that this needs an exclusivity primitive was right; the instinct that it needs a > **lock** was not. Sitting in a car is not mutual exclusion — it is **containment**. The > person-patron does not get barred from the car-patron; it goes **inside** it. > > **The mechanism is the ninth wire, `contains`** (§3): an index to the patron currently held > inside this one, or none. Getting in sets it; getting out clears it. The contained patron > keeps its own independent header and residency — it is still ranked, still heats and cools > like anything else — `contains` only records the relationship. > > **Reap is gated, not derived.** A patron with a non-none `contains` link cannot be reaped. > This is checked ahead of density ranking, as an absolute rule, not inferred from mass or > density — a light, cold container with something inside it must not read as evictable just > because the numbers say so. This is what actually answers the use-after-free concern §9 > raised: the engine cannot select an occupied patron for reaping in the first place. > > **Containment chains, and unwinding is forced, not chosen.** Because a patron can itself be > contained, `contains` links can form a chain — a patron inside a patron inside a patron. > If A contains B contains C, A cannot become reapable until B is empty, and B cannot become > reapable until C departs. This ordering is **topological, not a policy** — there is no > FIFO/LIFO choice to make here; the chain's own shape forces innermost-first. > > **Bounded, single occupant per level.** Each patron holds at most one `contains` link > (single occupant, not a set). Chain depth is capped — **default 5** — enforced by the > engine at containment-entry time (refuse to nest past the cap). The cap is a **Kconfig > symbol**, not a hardcoded constant, consistent with this project's existing tunable-knob > convention (`STARFORTH_ENABLE_VM`, `HOTWORDS_CACHE_SIZE`, etc.) — named at implementation > time in item 3.1, default 5, scannable via `menuconfig`. > > **What this leaves genuinely open, deferred, not blocking:** if multiple independent > containment chains are simultaneously blocked and waiting to unwind, whether the engine > gives any of them priority over another is a scheduling question, not a header-design one. > It does not affect the wire, the reap gate, or the depth cap, and is left for whenever it > becomes a real concern. > > §22.3's earlier remark that separate-region layout gives "physical fault containment" that > the single-cell-pool layout gave up is unaffected by this — `contains` is a logical > reference within one VM's own Stadium, the same trust boundary that layout decision already > accepted. --- ## 9. The admission test Before writing code, run this on paper against every candidate entry type. Two questions, both of which must have a non-forced answer: 1. **What does heat mean for this thing?** 2. **What is its reap event?** **COMPLETE.** Run against every candidate; all five patron kinds pass, and the two `?` marks are closed: | Type | Heat means | Governed by | Reap is | Verdict | |---|---|---|---|---| | Block | accessed often | heat decay | migration back to Artemis | passes | | Message | delivery urgency | TTL | delivery | passes | | VM | runs often | heat decay | death by cooling | passes (§20) | | Word | executed often | heat decay | cooling off the floor | passes (§17.3) | | ACL | checked often | TTL | **expiry** (§17.1) | passes | | ~~Screen cell~~ | — | — | — | **not a patron** (§17.5) | Screen cells were the suspect case and the suspicion was correct. A cell never expires — it is a fixed grid position always present, so cells-as-entries would leave most of the Stadium inert and permanently pinned. §17.5 confirms the reading anticipated here: **the patron is the dirty event, not the cell.** The grid stays outside, and the event turns out to be a message with a different destination rather than a sixth kind. Words were added to the table by §17.3 — the original list omitted them because §1 treated the dictionary as the *reference implementation* rather than as a population of patrons. Ten minutes on paper. It confirmed the design and caught one case, which is what it was for. --- ## 10. Sequencing **FABRIC.md first, then Hermes native on the fabric, then measure, then Console, then Artemis last.** > **Amended by §16.5 and §21.2.** This ordering is still right for the *subsystems*, but it > is not the first work. A substrate floor sits beneath all of it: Hera alone, real timer > interrupts and a real IRQ return path on all three ISAs (§16.1), and compudynamics driven > from that tick. None of the sequencing below can begin until that exists, because the > engine has nothing to run on. §25 carries the actual order. Reasoning: - Hermes is unfinished, which is lucky. Finishing it the old way and refactoring later means deliberately writing code already slated for deletion. Build it on the fabric directly and it carries zero migration debt. - It becomes the proving ground — the fabric gets tested against a real subsystem before anything that currently works is touched. - **It produces the effort number empirically.** What Hermes costs is the multiplier for everything else. One data point from real work beats any amount of estimating. - Artemis reads, writes, and persists reliably today. That is banked. It goes last, because it is the thing you cannot afford to break. Existing instrument: the POST suite exercises every dictionary word and was already earmarked as the regression gate for the shrink-to-colon-definitions pass. Same tool, second job. **Caution:** a green POST suite does not mean K still holds. Those are different claims. The DoE campaign validated K on the *current* substrate; changing the substrate means re-running it. Automated, but budget for it. --- ## 11. Where the debt accrues - **Dual paths — avoidable, and the big one.** Never two live heat mechanisms at once. Convert one subsystem completely, prove it, move on. Every shim bridging old and new is debt, and new code will get written against whichever is convenient. - **Speculative generality — avoidable.** Only add a wire when a second entry type needs it. Generality that never pays back is still debt. - **The exception — not avoidable, so decide it early.** If one subsystem does not fit and gets special-cased, that special case is permanent and worse than not unifying: you carry the general machinery *and* the exception, and every future reader learns both. This is why the admission test comes before code. **Early signal:** ARTEMIS.md, HERMES.md, CONSOLE.md and TRIPOD.md each currently describe their own heat mechanics. After FABRIC.md, each should shrink to roughly three lines — what an entry is here, what heat means, what the reap event is. If any one of them gets *longer*, that subsystem is fighting the fabric, and you will know which one before writing code. --- ## 12. Open questions — five closed, one partial **Status as of §24.** Five of the six are answered or dissolved; Q5 is partial. The original text is kept below because several later sections quote it. | | Question | Outcome | Where | |---|---|---|---| | Q1 | Payload threshold | **dissolved** — large patrons are simply heavy | §23.1 | | Q2 | Entry header size | **sized** — 64-byte cell, ~32-byte header (constants to validate) | §23.3 | | Q3 | Screen cell or dirty event | **event**; the grid is not a patron | §17.5 | | Q4 | Per-VM share hard or elastic | **elastic**, via quota over one pool | §22 | | Q5 | Loop coupling / timescales | **partly** — capacity must move slower than heat | §22.4 | | Q6 | One region or nested per VM | **nested**, two levels | §21 | Q5 is marked *partly* deliberately: §22.4 fixes the one ordering that matters (capacity slower than heat) but the full eight-loop interference analysis has not been done, and §16.1 notes it cannot be until a real time base exists on all three ISAs. --- 1. Payload threshold — what size goes inline versus by reference. 2. Arena entry header size. Cardinality spans orders of magnitude (dozens of VMs, thousands of messages, potentially very many screen events). The header must be sized for the worst case, and that case is the screen. Sizing this constrains everything else, so settle it early. 3. Screen cells: entry-per-cell or entry-per-dirty-event. (Leaning: event.) 4. Per-VM share — hard bound or elastic under pressure. 5. Loop coupling. Roughly eight feedback loops once Hera and heartbeat depth are counted. The algorithms are known; the risk is interference. Usual discipline is separation of timescales — keep nested loop periods an order of magnitude apart. Cheaper to decide than to debug. 6. Whether the arena is one region for the whole system or nested per VM. Nested implies K conserved at each level with messages as the only thing crossing a boundary, which would mean no shared-memory atomicity is ever needed. Single region is simpler but reintroduces locking — the one mechanism this architecture has otherwise never wanted. --- ## 13. What this does to formal verification This may be the largest payoff, and it was not the reason for the change. Verifying four subsystems means four state models, four conservation arguments, and — the expensive part — proofs about how they interact. That last category grows combinatorially and is where a verification effort usually dies. Unification deletes it outright. What the design gives Isabelle/HOL, more or less for free: - **One datatype.** The Stadium entry is a single record. Everything else is payload. You reason about `entry` once rather than about blocks, messages, VMs and events separately. *(Amended by §3: a cell is a two-constructor sum — patron header or continuation cell — not a bare record. That costs one case split and nothing else; the point stands.)* - **No pointers.** Fixed-size cells with index links means the Stadium models as a total function over a finite index set — no heap model, no separation logic, no aliasing, no null. This is the single biggest difference between a tractable proof effort and a research project. - **Finite state.** Bounded capacity means the state space is finite. Induction over the Stadium is straightforward, and model checking becomes available alongside theorem proving. - **One conservation theorem.** *Every engine operation preserves K.* Proved once against the engine, it holds for every entry kind — because the engine cannot distinguish them. Previously this was four proofs plus their interactions. - **A clean model boundary.** Storage below and devices beside the Stadium means disk I/O and framebuffer writes sit outside the model, at the C primitive boundary already drawn. - **A trivial initial state.** Boot order — kernel, then Stadium, then engine, then Hera — gives a base case that is trivially conserving, with everything else following by induction on operations. **One constraint this imposes, and it is not optional.** The code field is late-bound behaviour, which is the one part of this that HOL does not like: an arbitrary function stored in a record is higher-order and can wreck termination arguments. The fix is a design rule rather than a proof technique: > **The set of code-field behaviours must be a closed enumeration, fixed at build time.** Model it as a datatype of behaviour tags plus a dispatch function and the whole thing stays first-order and tractable. Leave the code field open as a general extension point and you have traded four easy verification problems for one genuinely hard one. This is consistent with the existing rule that adding a primitive requires rebuilding from source rather than doing it from inside a running system. Worth stating explicitly in the fabric design, because it is the kind of constraint that gets casually violated later by someone adding "just one" dynamic behaviour. --- ## 14. Formalism The thermodynamic analogy holds in places and inverts in one, which matters for the paper but not for the build. - Fixed capacity → closed system. K≡1.0 → conservation. Capacity transfer → work. These map cleanly. - **Heat is not entropy.** Heat is closer to energy or temperature. Entropy would measure how heat is *distributed*: concentrated is low, uniform is high. - **This matters practically.** K is conserved, so K can never tell you anything — it is 1.0 by construction, a correctness check rather than a diagnostic. Entropy over the heat distribution actually varies, and distinguishes idle from productive from thrashing. That is the real instrument, and the quantity worth driving the LED matrix with. - **The inversion:** the second law says entropy rises spontaneously. This system does the opposite — it self-organizes, concentrating heat where work happens. That is not equilibrium thermodynamics; it is a **driven dissipative system**, order sustained by throughput. Prigogine, not Carnot. A stronger claim, but only if stated correctly — writing "thermodynamic system" while entropy decreases unprompted is an easy shot for a reviewer. Phenomenon first, then mathematics. The formalism follows the phenomenon; it does not gate the build, and it is not finished until it is correct. --- ## 15. The whole thing in five lines - The Stadium holds the live crowd. Storage is the warehouse. Devices are the utility. - One entry shape. The code field is the only difference between kinds. - Traffic confers heat. Heat is conserved at 1.0. Density is heat per cell. Ranking reads density. - Departure is TTL, or cooling, or never. Pinning is invariance, not longevity. - The kernel opens the hall. Hera walks in first, and cannot be asked to leave. *(Amended from the original five by §17.5, §19.5, §17.1 and §20.5 #3. The earlier third line — "heat is density, conferred by traffic" — conflated two quantities; the earlier fourth — "departure is unconditional" — knew only one mechanism.)* --- ## 16. Substrate findings — 2026-08-03 Naming: the arena is now called the **Stadium**, because `src/starkernel/vm/arena.c` already owns "arena" for the PMM-backed VM page allocator — an unrelated concept. The document has since been swept to the new name, and §1–15's *substance* reconciled against §16–24 with each superseded claim marked in place. Four findings from reading the tree. The first three change what step one costs. The fourth changes what the engine is allowed to be. ### 16.1 There is no interrupt return path on two of three ISAs The engine has to be driven from outside the VMs (§6), which in a kernel means interrupts. That mechanism does not currently exist on most of our targets. - `apic_timer_start()` is an explicit no-op stub on aarch64 (`arch/aarch64/apic.c:82`) and riscv64 (`arch/riscv64/apic.c:76`). Both say the driver is deferred. - `heartbeat_tick()` is defined on all three architectures and *called* from exactly one site in the tree: `arch/amd64/interrupts.c:337`. On the other two it is dead code. - Worse: every vector in `arch/aarch64/isr.S` — IRQ included — is a bare branch to a handler that prints and enters `for(;;) wfe`. `arch/riscv64/isr.S` is the same shape. There is no register save, no `ERET`, no `SRET`. So enabling a timer interrupt today halts the kernel on the first tick. The work is not "write a timer driver," it is "build the interrupt return path that was never built." **Consequence for §12 Q5.** That question assumes a hierarchy of loop periods kept an order of magnitude apart. Separation of *timescales* presupposes a time base. There is one real time source, on one architecture; everything else paces off execution count. Q5 cannot be answered on the current substrate — it is downstream of this work, not parallel to it. ### 16.2 riscv64's time base is a guess `arch/riscv64/timer.c:46` sets `s_counter_hz = 1000000000ULL` with the comment `/* assume 1 GHz */`. The file header concedes `rdcycle`'s frequency is not architecturally discoverable. Every heartbeat variance and TIME-TRUST figure riscv64 has produced was computed against a wrong `expected_delta`. This has to be fixed as part of any timer work, and it means riscv64 timing numbers before and after that fix are not comparable. ### 16.3 The dictionary is already a Stadium §1 claims the dictionary is the reference implementation. It is stronger than that — but not as strong as an earlier draft of this subsection claimed. Read against `DictEntry` (`include/vm.h:335-351`): | §3 wire | In `DictEntry` | Form | |---|---|---| | identity | `word_id` + `name[]` | correct | | heat | `execution_heat` + `physics` | correct | | TTL | `acl_ttl` | correct | | pin | `acl_pinned`, plus `WORD_PINNED` / `WORD_FROZEN` | correct | | link | `struct DictEntry *link` | **a pointer, not an index** | | code field | `word_func_t func` | **a raw function pointer, not an enumerated tag** | | mass | — | absent | | payload | — (definition body lives outside the entry) | absent | **Four wires present in correct form, two present in the wrong form, two absent.** An earlier draft said "six of eight" and named the missing two as mass and a behaviour tag, which double-counted the code field and omitted payload. The wrong-form pair is the interesting part. `link` being a pointer is precisely what §13 identifies as *"the single biggest difference between a tractable proof effort and a research project,"* and the raw function pointer is what §18.3 requires to become a closed tag. So the honest claim is weaker than "the dictionary *is* a Stadium entry" and still strong enough to carry §1: **the dictionary already has the concepts, and two of the eight need to change form.** Everything else is what gets generalised toward it. **But run §9's admission test on it before moving it in.** Its reap event is the weak wire. Blocks migrate, messages deliver, VMs die by cooling — a dictionary word does not expire. Heat decays to a floor and the word stays; `FORGET` is manual and rare. That is the same shape §9 already flags as **suspect** for screen cells: hundreds of permanently resident, largely inert entries. It may well be fine, but the dictionary is too central to wave through, and it is precisely the case §9 exists to catch. **Also:** the dictionary is what `parity.c` hashes. Moving its representation into the Stadium changes that hash, so every committed baseline in `logs/` shifts. Not a blocker — but a deliberate re-baseline with a before/after record, not something to discover later. ### 16.4 The engine must stay deterministic — this is a new constraint Nothing in §1–15 says this, and it binds the engine tightly. `parity.c` logs a dictionary hash per VM birth. The DoE's 0.000% CV across 90 runs and the patent support material both rest on the same capsule producing the same heat state on every run. Today that holds for a reason worth naming: ticking is **execution-driven**. `vm_tick()` (`vm/vm_runtime.c:114`) is called from execution paths, and its own header says *"Synchronous (now): Called from main execution loop, every N executions."* Same instruction sequence, same tick points, same decay events, same hash. Wall-clock ticking does not have that property. Under TCG, elapsed time varies run to run on identical input. > **The interrupt may supply pacing, but the engine must fire on tick *count*, never on > elapsed wall time.** Same input → same tick ordinal → same reap and inference events → same hash. This keeps parity intact while still letting compudynamics be genuinely timer-driven. There is a second, narrower version of the same discipline. `heartbeat_tick()` measures inter-tick deltas to derive variance and TIME-TRUST. If the engine's own work ran inside that handler, the handler's runtime would become part of the interval it measures — the instrument would be reporting the cost of running the instrument. So the interrupt does bookkeeping only; the engine runs outside it. The split already exists in the tree and works: `adaptive_check_accumulator` / `adaptive_pending` (`include/vm.h:113-114`), set at `rolling_window_of_truth.c:372-375`, serviced at `:1302-1308`. **DECIDED** unless argued — it is a constraint inherited from what the system already claims, not a new preference. #### Corrected by the GAP-A1 ruling — the tick is virtual The rule above ("fire on tick count, never on elapsed wall time") was necessary but not sufficient, and its inference — *same tick ordinal → same hash* — was unsound. The hash covers `execution_heat`, which is co-written by **two streams**: word executions and engine ticks. A hardware timer makes the *interleaving* of those streams wall-clock-dependent under TCG, so same-per-tick actions do not compose into the same hash. See §25.7.1 GAP-A1 for the full argument. **RULED 2026-08-03:** > **The engine's tick is a virtual tick — a pure, deterministic function of the execution > stream.** This is what exists today (`vm_tick()` paced every N executions) and it is why > parity holds today. The hardware heartbeat is the TIME-TRUST instrument, the idle wake > source, and the driver of **nothing that feeds patron state.** When the system is idle, > the REPL poll loop pumps virtual ticks so TTLs still expire in real time — a context in > which parity was never claimed. Phase 0's timer bring-up remains fully justified: it makes the instrument real on three ISAs instead of one, and it is the substrate SMP will eventually need. What it does not do is drive the engine. Whatever step one turns out to be, it now has a floor under it: real timer interrupts and a real IRQ return path on all three ISAs. §10's sequencing (Hermes first, as the proving ground) sits above that floor, not below it. --- ## 17. Patrons An occupant of the Stadium is a **patron**. Blocks, words, ACLs, messages **and VMs** are all patrons. The word is doing real work: it names the category without implying a class hierarchy, and it keeps the metaphor honest — patrons attend, they are not the building. VMs were omitted when this section was written and added by §20, which found they were already implemented as the outer level. **Five kinds, not four** — the counts elsewhere in §17 predate that and should be read accordingly. **DECIDED.** ### 17.1 Patrons die several different ways — and that is not a type field The observation that prompted this section is correct: these things do not all end the same way. A message is consumed. An ACL lapses. A block should never be destroyed. A word should never be destroyed either. The reflex is a decision branch on patron kind. That is the type field §3 forbids, and it is not needed — but neither is the opposite over-simplification, which an earlier draft of this section made and which is corrected here. **Heat and TTL are not the same mechanism, and neither is a special case of the other.** §3 lists them as separate wires and they must stay separate. A message carries a countdown. A block does not — a block leaves the floor because it *cooled*, not because a timer expired. Collapsing the two forces the design, which is precisely the failure §11 warns about. There are three mechanisms, and each patron uses the ones that genuinely apply: | Mechanism | Nature | Patrons | Departure | |---|---|---|---| | **TTL** | countdown to a definite event | messages, ACLs | expiry | | **Heat decay** | continuous, gradual | blocks, words | cooling off the floor | | **Pin** | invariance — §3's wire | any | never | Mapped per patron: | Patron | Governed by | Reap event | |---|---|---| | Message | TTL | delivery — consumed, gone | | ACL | TTL | expiry | | Block | heat decay | **migration back to Artemis** — evicted, not destroyed | | Word | heat decay | cooling off the floor (see §17.3) | | VM | heat decay | death by cooling (see §20); Hera is pinned (§20.5 #3) | #### Two measures, one clock This does **not** mean two clocks. Both mechanisms advance off the same tick — **the virtual tick of §16.4 as ruled**, a deterministic function of the execution stream, not the hardware heartbeat. TTL decrements on a tick; heat decays on a tick. They are two different *readings* of one counter, not two independent time sources. That is not a tidiness preference, it is forced — twice over. §16.4 requires the engine to fire deterministically so the same input reproduces the same dictionary hash. And the mechanisms cannot be split across clocks: TTL expiry has side effects on the instruction stream (a message expiring versus delivered changes what runs next), so a wall-clock TTL would corrupt heat downstream even if heat itself stayed execution-paced. One virtual clock for everything that touches patron state; the hardware heartbeat observes and wakes, never drives. > **One tick. Two measures. Three mechanisms.** The engine still asks nothing about patron kind. It advances the tick, applies whichever measures a patron carries, and calls the code field when a patron departs. A pinned patron never departs. There is no type interrogation — see §18 for how the dispatch works without one. ### 17.2 Reaping is not destruction The block case is the one that makes this work, and §9 already had it right: a block's reap event **is migration**. §5 puts storage beneath the Stadium, with blocks coming onto the floor when hot and going back off when cold. So a block is reaped in exactly the sense the engine means — it leaves the floor. Where it goes afterwards is the code field's business, not the engine's. A message's code field ends in delivery; a block's ends in a write-back to Artemis. Same event, different behaviour, no special case. This is worth stating plainly because "reap" reads as "free" and here it does not: > **Reap means leaves the floor. It does not mean destroyed.** **DECIDED.** ### 17.3 Words: the dictionary is the warehouse, hot words are the patrons §16.3 left words as the unresolved patron. Pinning all of them resolves nothing — several hundred permanently resident, largely inert entries is the §9 screen-cell failure with a different label, and it wastes the bounded capacity that density needs as its volume (§19.2; this sentence originally cited the K-denominator justification that §2's correction removed — D1). The better reading applies §5 unchanged. Storage sits beneath the Stadium. The **full dictionary sits beneath it too**, and only **hot words are on the floor**. This is not speculative — it already exists and is already measured: - `src/physics_hotwords_cache.c` maintains the hot-word set - `cache_hits_delta` is column 4 of the DoE CSV, "hot-words cache hits this tick" - execution heat (Loop #1) is what promotes a word; linear decay (Loop #3) is what cools it So the hot-word population is already a live, moving crowd with an existing promotion rule and an existing cooling rule. It is the crowd. The dictionary is the warehouse it is drawn from, exactly as Artemis is the warehouse blocks are drawn from. #### The existing cache is only half-aligned — and that is the argument for doing this Reading `physics_hotwords_cache.c` closely turns up something that strengthens the case rather than weakening it. **Heat governs admission to the cache. Nothing governs departure.** `hotwords_cache_promote()` (`:362-383`), when full, writes the new word to `cache[lru_index]` and advances that index modulo the size. That is round-robin. The field is named `lru_index`, the inline comment at `:365` says "LRU eviction: remove oldest entry (round-robin)", and the doc block at `:347` says "round-robin least-recently-used" — which is a contradiction in terms. Nothing anywhere tracks recency of use. Promotion is gated on `execution_heat > HOTWORDS_EXECUTION_HEAT_THRESHOLD` (`:283`); eviction consults heat not at all. The consequence is that the hottest word in the cache can be evicted purely because its slot came up in the rotation. That is a direct contradiction of §4: > *Ranking is read, not decided. There is no scheduler because there is no policy. The > Stadium is simply already in heat order when you look at it.* Round-robin eviction is exactly a policy — an arbitrary one, uninformed by the physics the rest of the system runs on. **This is the strongest practical argument for §17.3.** Moving words onto the Stadium is not a relabeling exercise; it repairs a real defect by deleting the arbitrary half of an existing mechanism. And it is measurable before and after: `stats.evictions`, `stats.promotions` and `stats.cache_hits` are already instrumented and already flow into the DoE CSV. Consequences if this holds: - Words need no pin exception. Their reap event is cooling off the floor — the same shape as a block's, one level up. - §16.3's objection dissolves. The dictionary does not move into the Stadium wholesale; it stays beneath it and pages against it. - The parity concern in §16.3 narrows considerably. The dictionary's own representation is not what changes — what becomes a patron is the hot set, which is already transient. - Pin stops being a general-purpose escape hatch and goes back to meaning what §3 says: invariance, for the few things that genuinely must not vary. ~~**LEANING.**~~ **DECIDED 2026-08-04**, on paper, before item 4.1's code — settled after starting that item surfaced this section as its unresolved prerequisite. **The core claim stands, for the reason already given above.** The existing cache is self-contradictory as built: heat gates admission, nothing governs departure, and the consequence is that the hottest word in the cache can be evicted purely because its slot came up in the round-robin rotation. Moving words onto the Stadium repairs a real defect, not a relabeling exercise, and `stats.evictions`/`stats.promotions`/`stats.cache_hits` make it directly measurable before and after, exactly as this section already argued. **What was actually missing was the hosted/kernel split** — §25.5's own header warns "never two live heat mechanisms at once" (§11), but nothing said which way that resolves, and the two subsystems involved are not symmetric: - `src/physics_hotwords_cache.c` and `src/dictionary_management.c` are vendored, shared source. `dictionary_management.c` calls `hotwords_cache_lookup()` / `hotwords_cache_evict_*()` directly in the word-lookup path, **not gated by `#ifdef ENABLE_HOTWORDS_CACHE`** at the call sites — the toggle only controls the cache's own internal behaviour, not whether these call sites exist. This file must keep compiling and working correctly in both the hosted and kernel builds (CLAUDE.md is explicit). - The Stadium (`stadium.h`/`stadium.c`) is, and by construction of everything built through item 3.7 can only be, kernel-only — every declaration in it is `#ifdef __STARKERNEL__`. Nothing in this document has ever proposed a hosted Stadium, and building one is not something item 4.1 needs to do. - `ENABLE_HOTWORDS_CACHE` already defaults to **off** in both the hosted `Makefile` and `Makefile.starkernel` today (`Kconfig.physics`, confirmed against both Makefiles directly — a stale comment beside the Makefile default claims the opposite, but the actual default is `n`/`0` in both). So "two live mechanisms" is not a live conflict in the default build today; it only becomes one once item 4.1's kernel-side migration and the old cache are both actually exercised at once. **Resolution: kernel and hosted diverge, and that is the correct shape, not a compromise.** - **Kernel builds:** once item 4.1 lands, the old cache's *effect* is retired under `__STARKERNEL__` — word patrons migrate onto the Stadium, and `hotwords_cache_lookup()`/`hotwords_cache_evict_*()`'s call sites in `dictionary_management.c` are bypassed for the kernel build regardless of the `ENABLE_HOTWORDS_CACHE` setting. The shared source can stay compiled as-is (untouched, for hosted's sake) while being functionally inert on the kernel side. Item 4.1 decides the exact mechanism (a build-time gate, a runtime check, or something else) — not invented here. - **Hosted builds: unchanged.** No Stadium exists there, none is being built for it, and the existing mechanism — including its current off-by-default setting — stays exactly as it is. This is what keeps CLAUDE.md's dual-target compileability requirement satisfied without inventing a second Stadium implementation nobody asked for. Consequences from above still hold: words need no pin exception (reap event is cooling off the floor), §16.3's dictionary-parity concern narrows to the hot set alone, and pin goes back to meaning invariance rather than a general-purpose escape hatch. ### 17.4 Open 1. ~~**What is a word's TTL, concretely?**~~ **DISSOLVED — a word has no TTL.** The premise was wrong. §17.1 (as corrected) establishes TTL and heat decay as two distinct mechanisms, not one clock read two ways: words are governed by **heat decay**, and only messages and ACLs carry a TTL. Nothing needed unifying and no second mechanism was required. 2. ~~**Is the hot-word set bounded today?**~~ **RESOLVED — yes, hard bounded.** `DictEntry *cache[HOTWORDS_CACHE_SIZE]` (`include/physics_hotwords_cache.h:168`) is a fixed array inside the struct, with `HOTWORDS_CACHE_SIZE = 32` (`:84`). Nothing is allocated — `hotwords_cache_cleanup()` notes there is nothing to free, since the cache holds borrowed pointers the dictionary owns. It is per-VM (`vm->hotwords_cache`, used at `dictionary_management.c:320`), not global. This is exactly the inescapable outer wall §2 requires. Two things follow. **First, the bound is 32** out of a 453-word Mama dictionary — a very tight floor. Whether that is the right Stadium population or an artifact of the structure having been sized as a lookup cache rather than as a live set is a design input, not a given. **Second**, the eviction defect in §17.3 above. *Reported, not fixed:* in `hotwords_cache_promote()`, if `word` is NULL **and** the cache is full, the guard at `:363` falls into the inner branch at `:364` and writes NULL into `cache[lru_index]`. Unreachable today — every caller passes a non-NULL entry from the bucket search — but the NULL check reads as though it prevents this, and does not. 3. ~~**ACL reap**~~ **RESOLVED — TTL expiry.** ACL entries already carry `acl_ttl` in `DictEntry`, so they fall under the TTL mechanism in §17.1 alongside messages. §9's `?` is closed. 4. ~~Does a patron ever change what it is?~~ **RESOLVED in §24 — and the question was slightly wrong.** Full immutability is not available: FORTH blocks mutate in place by definition (`BLOCK` / `UPDATE` / `FLUSH`), while words already behave the opposite way, redefinition creating a new entry. The kinds genuinely disagree. What actually mattered was never payload but **mass and identity**. §24.2 states the invariant: identity never changes during a residency; mass never changes as a side effect of use; header fields mutate freely; payload contents may mutate provided size and identity do not. That gives §13 the enumerable mass function it needed without demanding immutability nothing could deliver. ### 17.5 The framebuffer is not a patron — it is a utility **DECIDED.** This is §5 and §2 applied rather than a new call, but it was close enough to becoming an exception that it is worth writing down explicitly. #### Outside the Stadium is not the same as an exception §11's warning is about a *patron kind that needs special handling inside the engine* — you end up carrying the general machinery and the carve-out, and every future reader has to learn both. That is the thing to fear, and the fear is correct. But §5 is not a carve-out. It is a taxonomy. The test for whether something is an exception is: **does the engine change because this thing exists?** For the framebuffer, nothing changes. The engine never learns about it. That is a boundary, not an exception. #### It fails §2's liveness test by definition, not by fiat §2's scoping decision is the sharpest line in this document: *the Stadium holds what is live, not everything that exists.* A patron arrives and departs. The framebuffer does neither — it is there from init to power-off. It has no arrival event and no reap event, not because it has been excused from having them, but because it genuinely has none. #### Better than "the building's lighting": a utility §5 calls the framebuffer the building's lighting, which undersells it — that reads like part of the structure. It is closer to **the power company**: external infrastructure the building consumes. Not the Stadium. Not the basement of the Stadium. A third thing. That gives three categories, all principled, none of them exceptions: | Category | Relation | Example | |---|---|---| | Warehouse | beneath | Artemis, the dictionary (§17.3) | | Stadium | the floor | patrons | | Utility | beside | framebuffer, and devices generally | #### What is live is the dirty event — and it is not a new patron kind *(Written when the taxonomy had four kinds; §20 has since added VMs as the fifth. The point stands unchanged — the dirty event adds nothing to the taxonomy at all.)* Run §9's two questions on it: - **Heat means** — a region written often is hot. A scrolling log, a blinking cursor. A static border is cold. Traffic confers heat, identically to everything else. - **Reap is** — redraw. Consumed by being painted. Consumed on delivery, carries a TTL, dies on arrival. **A dirty event is a message whose recipient happens to be the framebuffer.** It does not extend the patron taxonomy; it is the message patron with a different destination. Which yields a symmetry worth keeping: | Patron | Code field terminates at | Which lives | |---|---|---| | Block | Artemis | beneath | | Dirty event | framebuffer | beside | Both are code fields finishing outside the Stadium. Neither is special. **This closes the last `?` in §9.** The screen-cell row resolves to: the event is the patron, the grid is not. #### The sizing argument, independently A framebuffer is several megabytes of fixed device memory. Making it a patron means either swamping the bounded capacity (§2) — as mass, it would dwarf every other patron and make density comparisons meaningless — or forcing a by-reference payload path to exist for exactly one pathological object, which §23.1 has since abolished for patrons entirely. Sizing a design around its single largest outlier is how the header ends up wrong for the other ten thousand entries. *(This paragraph originally leaned on the K-denominator justification removed from §2 and on the pre-§23.1 payload framing; the conclusion is unchanged — D1.)* #### Not a patron does not mean no physics Worth stating so it is not lost: excluding the framebuffer from the Stadium says nothing about whether compudynamic concepts apply *within* it. A utility can have its own internal dynamics — heat over regions, decay, adaptive refresh — without being a Stadium participant. The power company has physics too. **OPEN, deferred.** What those dynamics are is a question for when the framebuffer work actually happens. It does not gate the Stadium, and it should not be designed speculatively now. ### 17.6 Sizing and allocation — the Stadium should be dynamic, but not heap-allocated §3 says the Stadium stays an array with index links. That is right, but it is stated in a way that invites the wrong objection, because **"array" and "fixed at compile time" are not the same thing** — and it is the second one that is genuinely objectionable. A hardcoded capacity is arbitrary: `HOTWORDS_CACHE_SIZE = 32` is a number someone picked, and §17.4 shows exactly how that ages. A contiguous block of fixed-size cells, sized at boot from the memory budget and addressed by index, is dynamic in every sense that matters operationally while remaining an array in every sense §3 and §13 depend on. Four positions, with what each costs: | | What it is | Cost | |---|---|---| | a | Capacity fixed at compile time | Arbitrary bound. What the hot-words cache does today. | | **b** | **Sized at boot, contiguous, index-linked** | **None. Retains every property below.** | | c | Contiguous but resizable at runtime | K's denominator moves; couples to §7 | | d | Per-entry allocation, pointer links | Forfeits §13 | #### Why (b) is free The Stadium is established before any VM exists (§6), so boot is already the moment its capacity is determined. Deriving that capacity from available memory rather than from a constant costs nothing and gives up nothing. Cells stay uniform, links stay indices, the region stays contiguous. **SUPERSEDED by §22.3 — the answer is (b) with a refinement.** The Stadium is one global array of cells sized at boot, and per-VM shares are **quotas held as counts** rather than separate regions. That keeps (b)'s properties while making (c)'s elasticity trivial, so the two are no longer alternatives. #### Why (d) is expensive — by this document's own argument §13 is unambiguous: > *No pointers. Fixed-size cells with index links means the Stadium models as a total > function over a finite index set — no heap model, no separation logic, no aliasing, no > null. **This is the single biggest difference between a tractable proof effort and a > research project.*** Per-entry heap allocation gives that up and takes several things with it: - **The finite state space.** Bounded capacity is what makes induction over the Stadium straightforward and what puts model checking on the table alongside theorem proving. - **§2's hard outer wall.** The bound is what gives density a volume to be dense within (§19.2) and §13 its finite index set. *(This bullet originally read "Without an inescapable bound, K is bookkeeping — §2 says this in as many words"; §2 no longer says that, and §20.2 established conservation is falsifiable regardless of the bound — D1.)* - **The engine's simplicity.** This is a freestanding kernel with `kmalloc.c` / `pmm.c` and no libc. Allocation in the reap path means the engine can fail to allocate, which means the engine needs a failure mode, which means it is no longer the thing §3 describes. An engine that can fail is a different engine. Fragmentation is the least of it, though §3 is right that indices avoid that too. #### Why (c) is the genuinely open one A contiguous region that grows and shrinks *as a whole* keeps index links and keeps the proof structure — the capacity becomes a parameter rather than a constant, which HOL handles without difficulty. What it complicates is K, since the denominator moves. This is not a new question. §7 already has it open for per-VM shares: *"whether a VM's share is a hard bound or an elastic one that can grow and shrink under pressure, with capacity transferring between VMs as a conserved operation Hera arbitrates."* Elasticity at the Stadium level and elasticity at the per-VM level are the same question asked at two scales, and they should be answered together rather than separately. ~~**OPEN**~~ **RESOLVED — and the prediction here was right.** Q6 did resolve to nested (§21), and (c) did become the attractive option (§22). But §22.3 found a cheaper route to it than resizing a contiguous region: with quotas held as counts over one shared cell pool, elasticity costs arithmetic on two integers and the denominator never moves at the level that matters. The total stays fixed; only the partition shifts. #### The rule this reduces to > **Dynamic in capacity. Static in structure.** Decide how big the Stadium is at runtime. Do not decide what an entry is, or how entries are addressed, at runtime. ### 17.7 Word-level heat conservation — DECIDED 2026-08-05, blocks item 4.1 until implemented Item 4.1 (hot words onto the Stadium, §25.5) needs word patrons to carry a Stadium `heat` share. The obvious move — `q48_from_u64(execution_heat)`, a pure representation change grafted onto `DictEntry.execution_heat`, no real conservation — was proposed and **rejected 2026-08-04**: Captain Bob wants real conservation for word heat, on the same footing as §19.1's fleet-level invariant, not just a unit conversion of the existing counter. **Corrected premise (2026-08-05): this was never a choice between converting `execution_heat` or leaving it alone.** `include/starkernel/vm/stadium.h:64` already declares the Stadium cell's `heat` field as `Q48.16, conserved share of 1.0 (§19.1)` — written when item 3.1 was done, before this section was reopened. Items 3.4 (density = heat ÷ mass) and 3.5 (admit if denser than the least-dense resident) already consume it as a real, relative, conserved quantity. **L0 already has a genuine conservation mechanism; it has just never been fed, because nothing has been admitted to the Stadium yet.** `execution_heat` and Stadium `heat` are two different fields with two different jobs. Item 4.1's task is to feed the second one from word dispatch, not to convert the first one into it. That reframing resolves four of the five questions this section left open: 1. ~~**The promotion threshold breaks.**~~ **RESOLVED — it doesn't, because nothing replaces it.** `HOTWORDS_EXECUTION_HEAT_THRESHOLD` belongs to the old cache mechanism, and §17.3's resolution already retires that mechanism's *effect* under `__STARKERNEL__` regardless of this question. Item 4.1 admits by item 3.5's rule instead — density relative to the least-dense resident — which is already the relative trigger this bullet said conservation would require, and it is already built. 2. ~~**`dict_hash` moves.**~~ **RESOLVED — it doesn't move, because `execution_heat` is not touched.** `capsule_dict_hash_hook()` keeps hashing name and `execution_heat` exactly as today; the counter keeps incrementing and decaying exactly as today. Stadium `heat` is not part of `dict_hash` and item 4.1 does not need to add it there. No baseline discontinuity, no "before vs. after" comparison problem — there is nothing to reconcile. 3. ~~**What transfers, from whom, on every dispatch — and its cost.**~~ **RESOLVED 2026-08-05 — a reservoir, not a fan-out, keeping the transfer O(1).** `vm_physics_touch()`'s proportional pull across every other live VM (`capsule_vm_physics.c :281-355`) is O(n) over the fleet and is explicitly justified there only because fleet touches are rare — the file's own comment (`:47-49`) contrasts "dozens to low hundreds" of fleet touches against word executions "in the millions." Copying that shape for words is not viable. Instead, each VM's inner Stadium gets one additional scalar — the **reservoir** — holding whatever heat is not currently claimed by a resident patron. All word-heat transfers are two-party, against the reservoir, mirroring Hera's structural role at the fleet level (a single fixed point that absorbs and donates) rather than the fleet's peer-to-peer fan-out: - **Touch** (dispatch of an already-resident word): pull a fixed Q48.16 quantum from the reservoir into the word's cell, clamped at what the reservoir holds. O(1). - **Cooling** (Loop #3's decay shape, redirected): return heat from the cell to the reservoir instead of letting it vanish. O(1) per word, same as today's independent decay. - **Eviction:** the cell's *remaining* heat must flow back to the reservoir before the cell returns to the free list (item 3.7) — otherwise conservation breaks on every reap. - **Quantum size:** a Kconfig constant, not an inferred rate. The fleet needed a statistical estimator (`VMFleetWindow`, `vm_physics_tick()`) because its touches are rare and irregular; words already have a simpler precedent — `execution_heat`'s existing per-dispatch increment is a flat `+1`, not tick-scaled. Mirroring that shape avoids a second estimator. Actual tuning is DoE work (item 5.1), not decided here. **Admission is the starter grant, by explicit choice (2026-08-05) — `execution_heat` plays no role.** A non-resident word has no cell, so its density is 0 and it can never win item 3.5's "denser than the least-dense resident" comparison on its own. Two shapes were weighed: (A) gate admission attempts on `execution_heat`'s existing threshold crossing — free, since the increment already happens, but makes `execution_heat` the promotion governor in kernel builds, directly against the "one governor per build" rule below; or (B) every dispatch of a non-resident word requests a fixed starter quantum from the reservoir and is admitted iff that quantum's density beats the current least-dense resident — `execution_heat` stays fully inert, matching the rule as already committed. **Chosen: (B).** **Correction (2026-08-05): the cost below was stated wrong.** An earlier draft of this paragraph took item 3.5's own commit note — written before item 3.7 — at face value instead of reading `stadium_admit()` as it stands today (`stadium.c:312-381`). The free list landed with item 3.7 and **is the primary path**: an O(1) pop, no scan, no comparison. The O(N) fallback only runs once a VM's own free list is exhausted, and even then it scans only that VM's own resident cells (`stadium_owner[i] != slot`), never the global array. Option B is O(1) in the common case — every admission attempt, for as long as the touched VM's Stadium floor has free capacity — and only degrades once that VM's floor is genuinely full, which is exactly when a real ranking decision (not a workaround) is the correct thing to be paying for. Better-justified than the original draft, not merely corrected. 4. ~~**What happens to `dict_hash` and parity comparisons that predate this change.**~~ **RESOLVED by #2 above** — nothing predates a change that isn't being made to the hashed field. 5. ~~**A new "L9" loop, or composes into an existing one.**~~ **RESOLVED — composes into L0.** The Stadium engine (§18) already owns a conserved heat wire per cell; item 4.1 populates that existing wire for the word patron kind. It is not a new loop and needs no name. **What sums to what, and admission semantics — settled by code already written, plus the reservoir above:** per-VM Stadium, one pool per VM (words, blocks, ACLs, messages together, not a word-only sub-pool) — matching §21.4's "K conserved here, independently" and the `stadium.h:64` field comment. **Correction to this section's 2026-08-05 earlier wording:** the invariant is not "residents sum to `Q48_ONE`" — the reservoir holds whatever residents haven't claimed, so the correct invariant is > Σ(resident patron heat) + reservoir == `Q48_ONE` checked the same way `vm_physics_conserved()` checks the fleet sum, epsilon-bounded. No reset on admit/evict: the total stays invariant across *any* call, so admission and eviction are transfers against the reservoir, never a reset — mirroring `capsule_vm_physics.c`'s VM-birth pattern (a new patron starts at 0, topped up by transfer) with the reservoir playing Hera's role: at VM-Stadium-quota-grant time, before any word patron is resident, the reservoir holds the VM's entire share, exactly as Hera holds the fleet's entire `Q48_ONE` before any other VM is born. **One governor per build — states explicitly what closes the §11/§25.5 "never two live heat mechanisms" gap:** - **Kernel builds:** `execution_heat` stops being a *decision input* once item 4.1 lands — it keeps incrementing, decaying, and getting hashed exactly as today (ENTROPY@, ACL words, diagnostics, `dict_hash` all keep working unchanged), but it no longer governs residency. Stadium `heat`/density governs Stadium residency instead. - **Hosted builds: unchanged**, per §17.3's own resolution — no Stadium exists there, `execution_heat` keeps governing the old cache exactly as it does today. This is the same per-build split §17.3 already ruled for the cache itself; word-level heat conservation follows it rather than inventing a second shape. **Open, deferred honestly rather than blocking:** - The quantum size (Kconfig constant, §17.7 bullet 3 above) has no value yet — tuning is DoE work (item 5.1), not invented here, same treatment as `STADIUM_MEMORY_PERCENT` (item 3.2). - Whether `rolling_window_seed_hotwords_cache()`'s POST warm-start (`rolling_window_of_truth.c:786`) needs a Stadium-side counterpart to seed word patrons' initial `heat` distribution from the reservoir — noted here as the natural seeding site, not designed. Item 4.1 may ship without it; POST warm-start of the *old* cache is unaffected either way since it writes `execution_heat`, which stays untouched. - `stadium_admit()`'s O(N) scans (item 3.5's own recorded debt) are accepted cost for word admission under Option B, not re-litigated here. They resolve when the free list (item 3.7) supersedes the full-array scan — tracked at item 3.5, not a new item. **This section now authorizes item 4.1 to wire word patrons onto the Stadium using: the reservoir-based O(1) touch/cool transfer, Option B's starter-grant admission (no `execution_heat` involvement), and the corrected invariant above. `execution_heat`'s current increment/decay behaviour and `dict_hash` remain explicitly out of scope — nothing in item 4.1 touches either.** #### Two rulings made during item 4.1's implementation, 2026-08-05 A pre-coding design pass surfaced two problems this section had not accounted for. Both were taken to Captain Bob before any file was touched; both are now closed. 1. **Cell-0 panic hazard.** `stadium_boot_init()` grants Hera's quota with `free_head = 0` so the first-ever admission pops cell 0 — documented as preserving item 3.6's "Hera is patron zero." But nothing had ever actually birthed Hera into the Stadium; item 4.1's first word dispatch would have made an ordinary, evictable word the accidental occupant of cell 0, arming `stadium_evict()`'s hard panic guard for the day something tried to reap it. **Ruled: birth Hera for real, as part of item 4.1** (a deliberate scope addition, not silently folded in) — `stadium_birth_hera()` admits a pinned, zero-heat, mass-1 candidate into cell 0 before anything else can reach it. Zero heat means no reservoir transfer is needed for her admission; conservation holds trivially at boot. 2. **Quantum/cool-rate underspecification.** The quantum this section names as Kconfig-tunable had no defensible starting value, and the cooling coefficient wasn't named as a *fraction of the patron's own current heat per tick* until this pass — the naive reading (reusing `execution_heat`'s flat per-tick decay shape directly) can zero a cell in one tick, since Q48_ONE (65536) is a much smaller number than it looks at a glance. **Ruled: two new Kconfig knobs**, `STADIUM_WORD_HEAT_QUANTUM` (default 2048 = Q48_ONE ÷ `HOTWORDS_CACHE_SIZE`, i.e. one "cache slot's worth" of the mechanism this item retires) and `STADIUM_WORD_COOL_RATE_Q48` (default 21845, reusing `INITIAL_DECAY_SLOPE_Q48`'s numeric value but reinterpreted as a fraction-of-current-heat removed per tick, unit-safe for a conserved share — NOT the same quantity as `execution_heat`'s decay, just a reasonable starting magnitude borrowed from it). Both flagged in their Kconfig help text as untuned placeholders, DoE work for item 5.1, same treatment as `STADIUM_MEMORY_PERCENT`. **Noted in passing, not fixed:** Kconfig/`menuconfig` itself has never been exercised end-to-end in this repo — every knob added so far, including these two, has only been verified via its `Makefile.starkernel` default, never through an actual `menuconfig` → `.config` → build round trip. Filed at §25.7. **Also found, reported not fixed (§25.7):** `stadium_admit()` never writes `stadium_owner[idx]` on either the free-list-pop or the eviction-fallback path. Harmless today — every cell's owner byte is already `0` (Hera) from `stadium_boot_init()`, and Hera is the only VM with a quota — but once item 4.2 restores Hermes, a resident's evict-credit would flow to the wrong VM's reservoir unless this is fixed first. --- ## 18. The engine — L0 The engine that holds the patrons is a loop like the others, and it needs a name in the same scheme. L1–L7 are taken by the existing feedback loops; L8 is the Jacquard mode selector. The engine sits **beneath** all of them, so: **L0**. ### 18.1 L0 and L8 bookend the gated loops This produces a structure worth drawing, because it explains why two of the ten are different in kind: ``` L8 Jacquard mode selector always on, ungated ───────────────────────────────────────────────────── L1 … L7 feedback loops gated by L8 ───────────────────────────────────────────────────── L0 the Stadium engine always on, ungated ``` L1–L7 are gated: L8 switches them on and off, 128 configurations over seven bits. The two bookends are ungated, and for symmetric reasons: - **L8 cannot be gated** because something has to decide the gates. A selector that could deselect itself has no defined behaviour. - **L0 cannot be gated** because it is what holds the patrons the other loops operate on. Switch it off and nothing is reaped, the Stadium fills and stays full, and K stops being conserved. That is not a mode, it is a failure state. This is the same argument §6 makes about boot order. The thing that manages existence cannot be a participant in what it manages — not for VMs, and not for loops. **DECIDED.** ### 18.2 The Jacquard accounting is an exclusion, not an extension The obvious reading of "add L0" is that the selector grows a bit: 7 bits becomes 8, 128 configurations become 256. **That is the wrong move, and §18.1 is why.** L0 is not gateable, so it has no bit. The gate word stays seven bits wide and the selector stays at 128 states. This is worth stating explicitly because the alternative is expensive: widening the gate word would invalidate the 128-configuration L8 table, the DoE campaign already run against it, and the existing results. There is no reason to pay that, and the design does not ask us to. > **L0 is accounted for in Jacquard by being deliberately absent from it.** **DECIDED.** ### 18.3 Dispatch: enumerate behaviours, not kinds §13 already requires a closed enumeration: > *The set of code-field behaviours must be a closed enumeration, fixed at build time… > Model it as a datatype of behaviour tags plus a dispatch function and the whole thing > stays first-order and tractable.* So a fixed enum with fixed dispatch is mandatory, not a concession to practicality. But there are two things one could enumerate, and only one of them preserves §3: | | Enumerate | Engine asks | Cost of a new patron kind | |---|---|---|---| | ✗ | patron **kinds** — `BLOCK`, `WORD`, `ACL`, `MESSAGE`, `VM` | "what are you?" | touch the engine | | ✓ | **behaviours** — `MIGRATE`, `DELIVER`, `EXPIRE`, `COOL` | nothing; calls `dispatch(tag)` | none | This was not hypothetical. VMs were added as a patron kind by §20 *after* this section was written, and cost the engine nothing — a VM's behaviour tag is `COOL`, the same tag a word carries. Under the rejected column it would have been an engine change. Both are closed, both are fixed at build time, both are equally provable. Only the second keeps the engine ignorant of its contents, which is the property §3 exists to protect. Two patrons may share a tag; a new patron that migrates costs zero engine changes. The branching Captain Bob is right to want is real and it is allowed — it lives in the dispatch function over a closed tag set, not in the engine asking patrons what they are. **DECIDED.** ### 18.4 One tick L0 advances on **the virtual tick** — a deterministic function of the execution stream, per the §16.4 ruling. Everything derived from time is derived from that one counter: - TTL decrements per tick (messages, ACLs) - Heat decays per tick (blocks, words) Two measures, one clock — see §17.1. §16.4 as ruled forces this: patron state must advance deterministically for the same input to reproduce the same dictionary hash, and the hardware heartbeat cannot supply that, because its interleaving with the instruction stream is wall-clock-dependent. The heartbeat's roles are the TIME-TRUST instrument and the idle wake source; when the system idles, the REPL poll loop pumps the virtual tick. ### 18.5 CLOSED — the adaptive rate does not break determinism, and here is why The concern: the heartbeat is *adaptive* — faster, slower, window wider, narrower. If it adapts off **timing measurements**, the adaptation is machine-dependent and §16.4 fails. If it adapts off **execution-derived state**, tick ordinals still map deterministically to work and parity survives. Traced end to end on 2026-08-03. **The dictionary-parity chain is clean.** Resolution (1) — adaptation inputs are execution-derived, TIME-TRUST stays diagnostic — is already the de-facto design. Evidence, in the order it decides the question: 1. **TIME-TRUST is computed and never consumed.** `heartbeat_trust()` has **zero callers** in the entire tree. `m5_time_trust` and `m5_variance` (`include/vm.h:315-316`) are declared and never read or written. The only consumer of `ts->trust` is `starkernel/doe_log.c:98`, which writes it to a CSV column. It is measured and reported, never fed back. 2. **The intent is already documented.** `include/starkernel/timer.h:70` — *"TIME-TRUST thresholds in Q48.16 (for diagnostics, NOT for gating)."* 3. **Every inference-engine input is execution-derived.** `vm_runtime.c:626-640` populates `InferenceInputs` from: the rolling window, `trajectory_length` (from `window_pos` / `total_executions`), `prefetch_hits` / `prefetch_attempts`, `hot_word_count`, `stale_word_count`, `total_heat`, `word_count`, and the previous check's baselines. **No timing input of any kind.** The outputs it applies — `adaptive_window_width` and `adaptive_decay_slope` — therefore depend only on execution history. 4. **Decay is tick-based, and deliberately so.** `vm_tick_apply_background_decay()` is handed `vm_monotonic_ns(vm)` but computes `elapsed_ticks = tick_count - last_decay_tick` (`vm_runtime.c:375`). The `now_ns` argument only writes `last_decay_ns`. The comment at `:373-374` says so explicitly: *"Tick-based, not wall-clock… now_ns is kept only to refresh last_decay_ns for diagnostics."* Someone already defended this exact boundary. 5. **The parity hash contains nothing time-derived.** `capsule_dict_hash_hook()` (`capsule/capsule_vm_hooks.c:60-70`) walks the dictionary hashing exactly two things per entry: **the word name and `execution_heat`**. Not `last_decay_ns`, not any timestamp. So even the diagnostic wall-clock field from (4) cannot reach the hash. **Conclusion: §16.4 holds today, and holds by construction rather than by luck.** #### One real exception, and it is not in the parity path `vm_physics_touch()` (`capsule/capsule_vm_physics.c:250-313`) **is** wall-clock dependent: it computes `elapsed_us = (now_ns - last_active_ns) / 1000` (`:272`) and the header comment at `:122` confirms the transfer amount scales with elapsed time. So **fleet-level VM heat is not reproducible run to run** the way dictionary heat is. Scope of that, precisely: - It touches `node->physics` in the VM registry, **not** `DictEntry.execution_heat`, so it does not reach the parity hash and does not invalidate the existing claim. - `vm_physics_tick()` (`:366`) explicitly discards its `now_ns` argument (`(void)now_ns;`), so only the touch path is affected. - With Hera alone this is nearly inert. It becomes live again when Hermes and Artemis return. This is a **pre-existing condition, not something the Stadium introduces.** But it is exactly the pattern L0 must not inherit, and it is worth knowing that fleet K figures and dictionary parity have different reproducibility guarantees today. #### The invariant this should become Determinism currently survives on convention plus one good comment. That is too thin for something load-bearing. L0 should make it explicit: > **Anything that influences patron state advances on tick count. Wall-clock time may be > recorded for diagnostics and must never be an input to a decision.** **DECIDED**, and it supersedes the "leaning (1)" in the earlier draft of this section. --- ## 19. Mass, density, and what K actually is §4 is marked LEANING with the note that *"the density formulation needs a concrete definition."* This section supplies it. It is the keystone: §4 claims ranking is **read** rather than decided, and that claim is empty until the thing being read is a number. The objection that forced this section is the right one. Density is *quantity per unit volume*, so it implies a mass and a volume. Neither had been named. ### 19.1 K is already defined, and it is not an occupancy ratio This has to come first, because the obvious definition of K contradicts working code. `vm_physics_conserved()` (`capsule/capsule_vm_physics.c:456-461`) sums `execution_heat_q48` across live VMs and tests that total against `Q48_ONE`: ```c uint64_t sum = vm_physics_fleet_heat_sum(); uint64_t diff = (sum > Q48_ONE) ? (sum - Q48_ONE) : (Q48_ONE - sum); return diff < VM_PHYSICS_EPSILON_Q48; ``` So: > **K is a conserved, normalised heat *share*. Total heat is always 1.0. Traffic transfers > heat to a patron from the others; it does not create it.** K is **not** occupancy, and defining it as `Σmass / capacity` would contradict an implemented, tested mechanism. It stays exactly as it is. ### 19.2 Three quantities, not one | Quantity | What it is | Range | Status | |---|---|---|---| | **Heat** | conserved share, moved by traffic | Σ = 1.0 always | already implemented | | **Mass** | cells the patron occupies — its footprint | integer ≥ 1 | new | | **Density** | **heat ÷ mass** — heat per cell | derived | new | Heat is the conserved quantity. Mass is an independent axis and never enters K. Density is the ratio, and it is density in the literal sense at last: quantity per unit volume, where the volume is a patron's own footprint inside the bounded capacity §2 requires. A patron holding a large share of the fleet's heat in a single cell is dense. A patron squatting on four cells with a negligible share is sparse, and belongs back in the warehouse. **DECIDED.** ### 19.3 Everything else reads off it The point of §4 is that no policy exists. With density defined, none is needed: - **Ranking** — order by density. Read, not computed by a scheduler. §4's first bullet is now true rather than aspirational. - **Admission when full** — admit the newcomer if it is denser than the least dense resident, and evict that one. This is a comparison of two intrinsic numbers, not a policy, and it closes the "what happens when the Stadium is full" gap. - **Hysteresis** — falls out unpaid-for. A heavy patron needs a proportionally larger heat share to hold its floor space, so a block sitting near the threshold does not oscillate on and off. No damping constant to pick, which is what §4 wanted and could not previously deliver. - **Migration cost is not a separate quantity.** An earlier draft of this reasoning treated cost-to-move as its own axis. It is not needed: footprint and cost correlate, because a patron is expensive to move precisely because it is large. Deriving cost from mass avoids introducing a second tunable, which §11 would rightly call speculative generality. ### 19.4 Correction to §4 — the self-limiting claim has the wrong mechanism §4's third bullet states: > *Popularity is self-limiting. A crowded entry is harder to reach, which throttles traffic > to it, which cools it. The governor is local and emergent — no global damping constant to > pick.* **The conclusion is right and the mechanism is wrong.** In a hall, a crowd physically blocks access to the car. In a computer the inverse is true — a hot entry is *easier* to reach, since that is the entire purpose of a cache. The metaphor does not survive translation, and no mechanism in this design reproduces the blocking effect because the effect is not real in this substrate. The real governor is **conservation**. Heat is zero-sum: total heat is 1.0, so a patron heating up necessarily cools every other patron, and nothing can exceed the ceiling. Popularity is self-limiting because there is a fixed amount of popularity to go around. This is §4's *second* bullet — *"K constrains the total, so ordering is forced by conservation rather than by tuned parameters"* — which was the correct answer already. The third bullet should be struck, not repaired. Designing a mechanism to make the crowd metaphor come true would be fitting the system to the analogy, which §14 already warns against in the other direction. **DECIDED.** §4's third bullet is superseded by this section. ### 19.5 Correction to §4 — "density generates heat" reverses the causality §4 says *"Density generates heat; nobody computes it."* Under §19.2 that is backwards, and the confusion is that one word was carrying two meanings: - **Traffic** generates heat — activity concentrated on a patron transfers heat share to it. §4's causality is correct with this word substituted. - **Density** is heat per cell — *derived* from heat, downstream of it, and it is the quantity that gets read when ranking. The corrected statement: > **Traffic confers heat. Heat is conserved at 1.0. Density is heat per cell. Ranking reads > density.** Nobody decides what matters at any step in that chain. §4's spirit is intact; only the noun was overloaded. ### 19.6 Open 1. ~~**What is mass, exactly, for each patron?**~~ **RESOLVED by §23.1** — the by-reference loophole is closed: if the payload is in the Stadium it counts toward mass, and what is not in the Stadium is not resident. The one residue — whether continuation cells are contiguous or linked, which shifts every large patron's mass — is §23.4 #4, scheduled as item 1.12. (D2) 2. ~~**Is mass constant for a patron's lifetime?**~~ **RESOLVED by §24.3** — mass changes only through an arbitrated transfer, never through traffic, so density is stable between transfers and §13 gets a mass function that changes at enumerable points. (D2) 3. ~~**How does traffic transfer heat between patrons, concretely?**~~ **RESOLVED by item 2.1** — `vm_physics_touch()` was restated on the virtual tick (no `now_ns` parameter at all), closing exactly the gap this point raised. This item's own cross-reference (*Refs:* §19.6 #3) points back here; this note closes the loop the other direction. Never annotated here at the time (found during the 2026-08-12 FABRIC-2.md audit). --- ## 20. VMs are patrons §17 named four patrons: blocks, words, ACLs, messages. That list is incomplete, and the omission matters because the missing kind is the only one already implemented. §9's admission table has always included VM — heat means *runs often*, reap is *death by cooling* — and §6 states it directly: *"Hera becomes the first entry in it."* Those cannot be reconciled with a four-patron taxonomy. **VMs are patrons.** Chronologically they are the first ones. **DECIDED.** ### 20.1 This is a finding, not a proposal The outer Stadium already exists in working code: - `vm_physics_fleet_heat_sum()` sums `execution_heat_q48` **across live VMs**, and `vm_physics_conserved()` tests that total against `Q48_ONE` (`capsule/capsule_vm_physics.c:456-461`). - That is a Stadium's K, computed over VM patrons. §19.1's definition of K was derived from it. - Hera already reaps VMs; `TRIPOD.md` makes governing existence her defining contract. So the mechanism §19 describes is not novel at the VM level. It is running now. ### 20.2 The outer level is unbounded — but fleet K is a real conservation law **This subsection previously claimed fleet K was "bookkeeping" that could not fail. That was wrong, and it was wrong on a point of fact rather than of interpretation.** It is replaced here rather than annotated. The error: it asserted heat is *renormalised* after population changes, without reading the paths where renormalisation would have to occur. #### Heat is transferred, not renormalised Read end to end in `capsule/capsule_vm_physics.c`: - **The primitive** (`:147-154`). `vm_physics_transfer()` subtracts from one patron and adds the same amount to another, clamped at zero. Its own comment: *"The one conservative primitive everything else is a special case of… Nothing is created or destroyed: sum(execution_heat for all LIVE VMs) is invariant across any call."* - **Birth** (`:156-185`). Hera (`vm_id 0`) is seeded with `Q48_ONE`; **every other VM starts at zero**, described as *"cold mass added to a closed system."* Population growth rescales nothing. - **Death** (`:225-247`). The dying VM's entire heat is *transferred* to the root it chains up to before being zeroed. - **Touch** (`:250-311`). Pulls from other live VMs proportionally, clamped to what they actually hold so it *"can never manufacture heat."* There is no renormalisation anywhere. `vm_physics_conserved()` tests a genuine invariant. #### It is therefore falsifiable — and there are two ways it can drift 1. **A documented leak** (`:240-244`). If a dying VM is itself the root, or its parent chain is broken, there is nowhere conservation-preserving to send the remainder and it is dropped. **The "guarded, shouldn't happen" description was only half right** — the Hera-is-root case was reachable via `S" Hera" KILL` (`mama_word_kill()` called `vm_physics_retire()` before `capsule_vm_kill()`'s own Hera guard ran), fixed 2026-08-13 (`FABRIC-2.md` §C). The broken-parent-chain case remains genuinely unreachable (`parent_vm_id` is set once at birth, never rewritten) and is not tracked further. 2. ~~**Truncation** (`:304-305`). The proportional fan-out computes `(moved_total * heat) / others_total` per VM in integer arithmetic. The shares sum to *less than* `moved_total`. **Every multi-VM touch loses a little heat**, so the sum drifts downward monotonically. `VM_PHYSICS_EPSILON_Q48` is 3277 — 5% of `Q48_ONE` — so given enough touches this would eventually trip.~~ **WRONG, corrected 2026-08-13** (`FABRIC-2.md` §B "Fleet heat leak," investigated when item 4.2 made this measurable) — the truncated `share` is what actually moves on *both* sides of `vm_physics_transfer()`, so the fleet sum cannot drift from this path; only path 1 above can. Left standing here per this document's own rule (§25 commit discipline: findings are corrected in place, not rewritten away) rather than deleted. #### What this means for the bound, and for the campaign **Bounding the VM population does not make conservation falsifiable — it already is.** The two are unrelated, and §2 has been corrected accordingly. The bound is still needed, for finite state (§13) and because density requires a capacity to be dense within (§19.2). It also changes the reading of the Artemis campaign's K-invariance arm. That arm was not measuring an identity in the trivial "cannot fail" sense the pre-correction text of this section claimed. It was measuring a quantity that *could* genuinely drift via path 1 above (the dying-VM-is-root/broken-chain case), and which did not drift, consistent with that path being guarded off during the campaign. **Not**, as this section originally went on to claim, because truncation (path 2) was silently eating into the margin — path 2 is now shown (2026-08-13 correction above) to be incapable of moving the sum at all. ~~**Reported, not scheduled:** the truncation leak at `:304-305` is a live defect in a conservation law the project makes claims about. It is small per touch and may be entirely tolerable, but it is monotonic, and nobody has measured how far it drifts over a long run.~~ **WRONG, corrected 2026-08-13** — see the strikethrough correction on path 2 above. Path 1 (the dying-VM-is-root/broken-chain drop) is the only live defect; it is tracked in `FABRIC-2.md` §C, not here, per this document's closure. ### 20.3 Nesting — §12 Q6 is less open than it looks If VMs are patrons, the structure follows without further invention: ``` Outer Stadium patrons: VMs ← exists today (unbounded) └── per-VM Stadium patrons: words, blocks, ACLs, messages ← to be built ``` K conserved at each level, with messages as the only thing crossing a boundary. That is precisely §12 Q6's *nested* option — *"K conserved at each level with messages as the only thing crossing a boundary, which would mean no shared-memory atomicity is ever needed"* — and the outer level is already there. This does not close Q6 by itself, but it changes the question. The choice is no longer between two greenfield designs; it is whether to formalise a nesting that is already half built, or to collapse it into a single region and discard the level that works. ~~**LEANING nested.**~~ **DECIDED nested in §21**, written immediately after this section (D3). See §20.5 for what still had to be settled. ### 20.4 A VM's mass is the capacity share Hera allocated it — RESOLVED by item 1.6 **RESOLVED 2026-08-04.** What follows was written as a proposal; it is confirmed here rather than rewritten, because everything since has already been treating it as decided. §22's elasticity mechanism (DECIDED) only means something if a VM's mass is its variable quota — "capacity flows down the density gradient" (§22.1) is vacuous if every VM's mass were pinned at one cell. §24.3 already states outright that "a VM's mass is elastic by §22." Items 1.2 through 1.5 (the resting floor, the transfer trigger, the timescale ratio, the outer bound) all already read mass-as-quota as given. §20.5 #2's own framing settles it independently: the one-cell alternative "throws away the distinction" in the table below, which is the entire reason for doing this. **A VM's mass is the capacity share — the quota — Hera allocated it**, not a fixed one-cell footprint regardless of size. Decided on paper, not yet built: `VMPhysics` currently holds only `execution_heat_q48`, `last_active_ns` and `is_live` (`capsule_vm_physics.c:59-63`). There is no share field yet — adding one is implementation work for a later phase, not this item. §7 says Hera's job is Stadium distribution, and that *allocating a VM's share is birthing it*. If that share is the VM's mass, §19's density definition applies unchanged at the outer level, and §7 stops being abstract. The payoff is that Hera gets a strictly better lifecycle signal than heat alone: | VM | Heat | Mass | Density | Reading | |---|---|---|---|---| | small, quiet | low | low | moderate | healthy — dense enough, merely small | | big, idle | low | high | **low** | **sparse — reap or shrink** | | small, busy | high | low | **high** | dense — a candidate to grow | Heat alone cannot distinguish *starved* from *small*. Density can. `TRIPOD.md` states that Hera uses the fleet K view for exactly this question — *"Is a child VM healthy? Is a child VM starved?"* — and density is the quantity that actually answers it. Note this stays within `TRIPOD.md`'s constraint that fleet K is **lifecycle telemetry, not a dispatch mechanism**. Density informs whether a VM should exist or change size. It never decides where work goes; that remains capability-based routing. ### 20.5 Open 1. ~~**Bounding the VM population.**~~ **RESOLVED by item 1.5 (§25.2), 2026-08-04.** What is the outer Stadium's capacity, and what happens at the bound — birth refused, or coldest VM reaped? **Correction first: "coldest reaped, consistent with §19.3" does not survive §20.2.** §19.3's admission rule is *admit if denser than the least dense resident*. §20.2 already decided every VM but Hera is born at heat zero. A newborn can never be denser than an existing warm VM, so applying §19.3 literally at the outer level means births at the bound would fail regardless — just silently, via a comparison that can never succeed, instead of by an explicit refusal. Treating "coldest reaped" as automatic would also require a bespoke, non-density rule that exists for VMs alone, which is exactly the per-kind special case §11 warns against. **Resolution: birth is refused at the bound.** Making room is Hera's own deliberate act — she already reaps VMs (§20.1) — never an automatic side effect of someone else's birth request. This is the explicit, stated behaviour the original text asked for. **The bound itself: 4, Kconfig-tunable, explicitly a placeholder.** 4 matches Tripod's own currently-known topology (Hera + two Hermes instances + Artemis) — the smallest number that doesn't already contradict what this system is known to need, not a padded estimate. It is expected to be too small for real workloads. The right way to find an idealized default is empirical — a DoE campaign, the same discipline already used elsewhere in this project (`experiments/bare_metal/`) — not a second guess made on paper. Tracked as future work, not invented here. The constant is a Kconfig symbol (e.g. `STADIUM_MAX_VM_COUNT`, named at implementation time in item 3.1 alongside the other new symbols this phase introduces), not hardcoded. **Fixed for the machine's lifetime once set at build** — resolves §22.5 #4 below in the same stroke. The outer total does not itself flex at runtime; only per-VM quotas do (§22). An outer bound that could grow or shrink live would mean §2's "inescapable wall" is not actually inescapable. 2. ~~**Is a VM's mass its allocated share, or one cell?**~~ **RESOLVED by item 1.6 — the allocated share.** See §20.4. 3. ~~**What is Hera's own mass?**~~ **RESOLVED — Hera is pinned, and her eviction is a panic.** She is the first patron and she governs the rest, so she is subject to §3's pin wire: invariance, not longevity. That is the correct use of pin rather than an exception to the rules. But pinning alone is a silent guarantee, and a silent guarantee that fails under load is worse than none. **If the engine ever selects Hera for eviction, that is a kernel panic**, not a skipped iteration and not a logged warning. The condition is unreachable by construction; reaching it means the invariant is already broken and continuing would run the system without a governor. State it as an assertion at the eviction site, not as a filter on the candidate set — filtering hides the bug, asserting reports it. Her mass is still whatever §20.4 resolves for VMs generally. Pinning governs whether she can depart, not how much room she takes. 4. ~~**Does the nesting recurse further?**~~ **RESOLVED by item 1.7 (§25.2), 2026-08-04.** A VM's Stadium holds patrons; if one of those patrons were itself a VM, the structure is a tree rather than two levels. Nothing currently requires this, and §11 would call it speculative generality — but it should be bounded deliberately, since the boot order in §6 does not forbid it. **Not the same question as §8's `contains` chains (item 1.1)** — those are same-Stadium patron-holds-patron relationships, bounded to depth 5, and do not create a second Stadium. This is specifically about a patron *being* a VM with its own nested Stadium underneath it. **Resolution: bounded by a Kconfig-tunable cap, default 2 — not a hard "never."** Consistent with how item 1.1 treated its own depth question rather than declaring a permanent architectural prohibition. 2 matches what §21 already decided and what already exists: the outer Stadium (patrons: VMs) and each VM's own inner Stadium (patrons: words, blocks, ACLs, messages). Nothing today drives a third level, so 2 is the honest default, not a padded estimate. The cap is enforced explicitly at VM-birth time — birthing a VM whose own Stadium would sit at a depth beyond the configured cap is refused, the same "explicit refusal over silent/emergent behaviour" discipline item 1.5 used for the outer bound — rather than left as an unstated assumption nothing checks. --- ## 21. §12 Q6 resolved — nested > *Q6: Whether the arena is one region for the whole system or nested per VM. Nested implies > K conserved at each level with messages as the only thing crossing a boundary, which would > mean no shared-memory atomicity is ever needed. Single region is simpler but reintroduces > locking — the one mechanism this architecture has otherwise never wanted.* **Resolved: nested.** The conclusion Q6 leaned toward is right; the reason it gives is not. **DECIDED.** ### 21.1 The locking premise is false — locking is already free Every mutex in the kernel build is a no-op. `src/starkernel/vm/host/shim.c:415`: ```c void sf_mutex_lock(sf_mutex_t *mutex) { (void)mutex; } ``` `dict_lock` and `tuning_lock` (`include/vm.h:410,507`) are real `pthread_mutex_t` in the hosted build (`platform_lock.h:58-63`), but the kernel compiles with `-DSTARFORTH_MINIMAL=1` (`Makefile.starkernel:253`) and the shim stubs them out. The stated rationale is accurate: *"Single-threaded kernel: no contention is possible at the VM level."* So the cost Q6 weighs against the single-region option is currently **zero**. The architecture has not avoided locking; it has locking, inert. Q6 cannot be decided on this basis. ### 21.2 Step one introduces real concurrency — and locks are the wrong answer for it This belongs in §16's substrate work, not here, but it surfaced while resolving Q6 and it lands sooner than anything the Stadium needs. Once the timer interrupt fires on all three ISAs (§16.1), **the ISR preempts the mainline.** That is genuine concurrency between two contexts sharing state on a single hart. It does not exist today, which is precisely why the no-op stub is currently safe. Making the mutexes real would not fix it and would actively break it: on a single hart, an ISR spinning on a lock the mainline holds **deadlocks outright**, because the mainline can never run to release it. This is a well-known failure and it is easy to introduce by reflex. The correct answer is already in the design — §18.4's top-half / bottom-half split: - **ISR (top half)** touches only a word-sized counter and a flag. Single writer. - **Mainline (bottom half)** is the only context that mutates Stadium structure. No lock, no deadlock, and no reliance on atomicity beyond aligned word access. This is a constraint on the L0 implementation, not a preference. > **Nothing in interrupt context may mutate Stadium structure. Ever.** ### 21.3 What actually decides Q6 With locking removed from the argument, six discriminators remain: | | Nested | Single region | |---|---|---| | Matches what exists | `hotwords_cache`, `rolling_window`, dictionary are already per-VM; the physics registry is already outer | collapses a working two-level structure into one | | Fault containment | a VM cannot corrupt another's Stadium | one bad patron reaches everything | | Capacity transfer (§7) | meaningful — VMs have shares to trade | no per-VM share exists to transfer | | K semantics | conserved per level; existing fleet K survives unchanged | fleet K needs re-deriving | | Verification (§13) | prove the engine once, instantiate at both levels — demonstrates genericity | one region, marginally simpler | | **If SMP ever happens** | **messages are the only boundary-crossers → no shared memory, still no locks** | **needs real locks, and the no-op stubs become a live correctness hole** | The last row is the strongest, and it is what Q6 was reaching for. Nested does not avoid locking *today* — nothing needs locking today. Nested avoids locking **permanently**, including in a multi-hart future where the current stubs would silently stop being correct. The first row is the most practical: §20.1 established that the outer level already exists and works. Single-region means discarding a working structure to build a simpler one, which is a poor trade at this stage. ### 21.4 The shape this fixes ``` Outer Stadium patrons: VMs │ K conserved here │ bounded — see §20.5 #1 │ ├── Hera's Stadium patrons: words, blocks, ACLs, messages │ K conserved here, independently │ └── (future VMs) same shape, no special cases ``` Messages are the only patrons that cross a boundary. Everything else is confined to the level it was born on. ### 21.5 Consequences and open items 1. **The no-op mutexes are now load-bearing in a way they were not before.** They are correct today and correct under nesting, but only while the top/bottom discipline in §21.2 holds. That discipline should be stated in the code at the stub site, so the next reader does not "fix" the no-op into a spinlock and deadlock the kernel. 2. **Two capacities to size, not one.** §20.5 #1 (outer bound) and §17.6 (per-VM bound) are now distinct questions with distinct answers. 3. **§12 Q4 / §7 / §17.6(c) elasticity becomes the live question.** Nesting is what makes capacity transfer between VMs meaningful, so the hard-versus-elastic decision can no longer be deferred as an abstraction — it is the next real fork. 4. ~~**§20.5 #4 remains open.**~~ **RESOLVED by item 1.7 (§25.2), 2026-08-04** — this section (§21.5) was written before that ruling and never updated to point to it. Bounded by a Kconfig-tunable cap, default 2, enforced at VM-birth time. See §20.5 #4 for the full resolution. Stale cross-reference, not new work (found during the 2026-08-12 FABRIC-2.md audit). --- ## 22. Elasticity resolved — elastic, via quota over a single cell pool §7, §12 Q4 and §17.6(c) are one question asked at three scales: is a VM's share of capacity a hard bound, or elastic under pressure with transfer arbitrated by Hera? **Resolved: elastic.** And the layout that makes it cheap is a single global cell pool with per-VM quotas, not separate physical regions. **DECIDED.** ### 22.1 Why elastic — §19 turns it into a feedback loop Under §19's definition, elasticity stops being a feature to implement and becomes a negative feedback loop that runs itself: ``` VM gets busy → heat share rises → density rises → capacity flows toward it → mass rises → density falls back ``` Capacity flows **down the density gradient** — from sparse VMs toward dense ones. That is diffusion. There is no threshold to choose, no damping constant, and nothing decides: it is §4's *read, not decided* applied one level up. A hard bound offers none of this. It offers a number that had to be guessed correctly at birth and stays wrong. §7's own argument is the practical half, and it holds: > *Under elasticity, birth sizes the rest volume rather than a cap — a more forgiving thing > to have to guess right.* Predicting a VM's resting size is far easier than predicting its peak, and being wrong self-corrects instead of persisting. ### 22.2 The connection to §14 Heat concentrates where work happens — §14's driven-dissipative inversion, order sustained by throughput. Capacity then follows heat. So the two distributions move in opposite directions: **heat concentrates while density equalises.** That makes the flatness of the density distribution a real, measurable signal of a settled system, distinct from the heat distribution's entropy that §14 already identifies as the instrument worth having. Two signals, not one, and they say different things. ### 22.3 The layout decision, which matters more than hard-versus-elastic Framing this as hard-versus-elastic obscures the real choice. Elastic is cheap or expensive entirely according to how the Stadium is laid out, and §21's nesting decision does not settle that. | Layout | Elastic cost | Isolation | §13 verification | |---|---|---|---| | Separate physical regions | expensive — transferring capacity means moving memory, and regions fragment against each other | physical | two index spaces | | **One cell pool, per-VM quota** | **trivial — arithmetic on two integers** | logical (disjoint index sets) | **one index space, one total function** | | Separate regions, hard bounds | n/a | physical | two index spaces | **Chosen: one global array of cells, one global index space.** Nesting becomes a *partition* of that index set rather than separate allocations. A VM's quota is a **count, not a contiguous range**, so there is no adjacency requirement, no fragmentation, and index links keep working because indices are global. #### Free lists are per-VM, not shared An earlier draft of this section said cells are drawn from a **shared free list**. That was wrong, and it quietly undercut the argument that decided §21. §21.3's decisive discriminator is the SMP row: *messages are the only boundary-crossers, so no shared memory and still no locks.* A shared free list is shared mutable state, touched by every VM on every admission and every reap. Under SMP it would need a lock or atomics — exactly what that row claims nesting avoids permanently. The defence offered there, that "VMs never touch each other's cells," does not reach it: **the free list is nobody's cell, and allocation touches it.** The fix costs essentially nothing: > **Each VM holds its own free-list head index into the global array.** Hera hands a VM its > cells when she grants quota; the VM allocates and frees only within what it holds. One head index per VM instead of one global head. One index space is preserved, one datatype is preserved, §13 is unaffected — and disjointness becomes **total** rather than nearly total. No mutable structure is shared between VMs at all, which is what §21.3 actually promised. Transfer of capacity is then Hera moving cells from one VM's free list to another's, which is still arithmetic plus a list splice, and still arbitrated at a known point (§22.5 #2). Two reasons this is the right trade: - **§13 gets simpler rather than harder.** One array, one datatype, one total function over one finite index set. A partition of a finite set is trivial in HOL. Separate regions would mean two of everything and a cross-region invariant to maintain. - **§21's reasoning survives intact.** Its argument for nesting was K conserved per level with messages as the only boundary-crossers — both preserved. SMP-safety also survives: what matters is that VMs never touch each other's cells, and disjoint index sets give that provided quota changes are arbitrated by Hera, which §7 already requires. What is given up is *physical* fault containment — a corrupt index could reach another VM's patrons where separate regions would fault instead. That was one of §21.3's six discriminators and not the decisive one. It is a real cost, recorded here rather than glossed. ### 22.4 Capacity moves slower than heat — required, not preferred Two conserved quantities in motion can oscillate. Heat moves on traffic; capacity moves on density. At comparable rates they chase each other and the ratio never settles. > **Heat responds tick by tick. Capacity responds to sustained density across many ticks.** This is §12 Q5's separation-of-timescales discipline — *"keep nested loop periods an order of magnitude apart"* — arriving as a concrete instance rather than general advice, and it partly answers Q5. The exact ratio is a tuning question, but the *ordering* is not: capacity must be the slower loop. Getting this backwards produces a system that thrashes while every individual rule looks correct. **RESOLVED by item 1.4 (§25.2), 2026-08-04 — 1000:1, grounded in an existing precedent, not picked from nothing.** `capsule_vm_physics.c:434-441`'s `vm_physics_heartbeat_tick()` already runs a fleet-level slow loop at `HEARTBEAT_INFERENCE_FREQUENCY` virtual ticks (default 1000, `starforth_config.h:72`) to recalibrate `fleet_transfer_slope_q48` — the rate `vm_physics_touch()` uses for heat transfers. Different mechanism (heat-transfer-rate recalibration, not capacity/mass transfer), but the identical shape item 1.3's capacity-tick needs: a coarse, fleet-level reassessment layered over the fine virtual tick, in the same file, same subsystem. The capacity-tick gets its **own** named constant rather than literally sharing `HEARTBEAT_INFERENCE_FREQUENCY` — they are conceptually separate concerns (inference-engine window/decay tuning versus capacity arbitration), and coupling them would mean retuning one silently retunes the other. But its **default is 1000**, matching this precedent rather than inventing an unrelated number. 1000:1 against the virtual tick is comfortably past §12 Q5's "order of magnitude apart" minimum. Named and made a Kconfig symbol at implementation time (item 3.1), same tunable-knob convention as item 1.1's containment-depth cap. ### 22.5 Open 1. ~~**The resting floor.**~~ **RESOLVED by item 1.2 (§25.2), 2026-08-04.** A VM that goes quiet loses capacity; if it wakes it may not regain it fast enough. The obvious guard is a floor below which a quota cannot fall — but that is a tuned number, which this design otherwise avoids. **Floor = max(mass of pinned patrons, one message-sized cell).** The first term is the principled alternative this section already named: derived, not tuned. The second term closes a gap the first term leaves open on its own — a VM with zero pinned patrons would otherwise get a floor of zero, and a VM with zero quota cannot receive anything, including the message that would be the reason for it to wake up and regrow via §22.1's density-gradient feedback. That is a deadlock: no capacity to receive, no way to ever regain capacity. One message-sized cell is itself derived, from §23.3's cell sizing rule ("size the cell so a typical message is exactly one cell"), not a second tuned constant — so the discipline this section wanted to preserve still holds with both terms in place. 2. ~~**What arbitrates a transfer, concretely?**~~ **RESOLVED by item 1.3 (§25.2), 2026-08-04.** §7 says Hera. Under §22.3 a transfer is arithmetic on two integers, so the mechanism is trivial — but *when* she does it, and on what signal, is not yet stated. **The cadence is the slow part, not a threshold.** Hera evaluates the density gradient once per **capacity-tick** — a coarser, derived multiple of the virtual tick (§18.4). The exact multiple is item 1.4's job, not fixed here. This is what gives "sustained density" (§22.4) its actual meaning: anything shorter-lived than one capacity-tick interval cannot trigger a transfer, without needing a magnitude threshold layered on top. **Whether to act, once she looks, is a pure comparison — no tuned threshold.** At each capacity-tick, Hera finds the single densest and single least-dense live VM. If they differ at all, a transfer is eligible. This is the same shape as §19.3's admission rule ("denser than the least dense resident") — a comparison of two intrinsic numbers, not a policy, so nothing needs inventing here. Both halves satisfy the tick-expressibility constraint item 1.3 states: the cadence is a tick multiple, and the decision itself reads only heat and mass, never wall time. **What this does not resolve:** how much capacity moves per eligible transfer. §22.3 only says the mechanism is "arithmetic on two integers"; neither this section nor item 1.3 pins down the amount. Reported rather than invented — it can become its own item if warranted, but is out of this item's scope. 3. ~~**The exact timescale ratio**~~ **RESOLVED by item 1.4 — 1000:1.** See §22.4. 4. ~~**Does the outer Stadium's own capacity ever change?**~~ **RESOLVED by item 1.5 — no.** §22 makes per-VM quotas elastic within a fixed total; the total itself is fixed for the machine's lifetime, set once at build via the Kconfig bound §20.5 #1 introduces. See §20.5 #1 for the full argument. --- ## 23. §12 Q1 dissolved, §12 Q2 sized ### 23.1 Q1 — the inline/by-reference threshold should not exist > *Q1: Payload threshold — what size goes inline versus by reference.* §3's motivation is sound: a cell sized for a 1024-byte block would be grotesque for a patron that carries twelve bytes. But §19 supplies a better answer than a threshold. If cells are small and uniform, a large patron **occupies more of them, chained by index**. That is exactly what mass already means. A block is not "by reference" — a block is **heavy**. This closes the loophole recorded in §19.6 #1 without introducing a rule: > **If the payload is in the Stadium, it counts toward mass. If it is not in the Stadium, > the patron is not resident — it is a handle to the warehouse.** A 1 MB block cannot occupy one cell and read as dense, because its bytes are on the floor and the floor is what mass measures. This is also what gives §19's hysteresis its teeth. Blocks *should* be expensive to keep resident — that is the entire reason migration back to Artemis is their reap event (§17.2). A threshold that let big patrons masquerade as light ones would have quietly disabled the mechanism. Nothing in §3 is violated: cells stay fixed-size, links stay indices, the Stadium stays an array. Multi-cell patrons are consistent with all of it. **By-reference is reserved for things genuinely outside the Stadium**, and those are not patrons. **DECIDED — Q1 is dissolved rather than answered.** ### 23.2 Q2's premise moved, and an unsettled question sits under it > *Q2: Arena entry header size… The header must be sized for the worst case, and that case > is the screen.* §17.5 removed the screen grid from the Stadium, so that premise no longer holds. What replaces it depends on something §17.5 established only halfway: it decided the **dirty event** is the patron, but not what one event *covers*. | Granularity | 80×25 full redraw | Consequence | |---|---|---| | per cell | 2,000 simultaneous patrons | floods the Stadium; starves every other patron | | **per line span / region** | **~25 patrons** | negligible | A two-order-of-magnitude swing, currently undefined. **Recommend region-based.** Framebuffer updates are naturally regional — a scroll dirties everything, a print dirties one span — overlapping regions coalesce for free, and per-cell events would make the console the numerically dominant patron kind in the entire system. That is absurd for something §17.5 correctly classified as a *utility* rather than an occupant. **LEANING region-based.** It is a console-design decision as much as a Stadium one, so it should be confirmed when the console work happens rather than fixed here. With that settled, the worst case for cardinality becomes **messages** — numerous, individually small. Which yields the sizing rule: > **Size the cell so that a typical message is exactly one cell.** ### 23.3 Concrete sizing — proposal, to be validated These are numbers to check against a real build, not derived truths. | | Value | Reasoning | |---|---|---| | Cell size | **64 bytes** | one cache line; keeps density-ranking scans cache-friendly | | Header — used | **28 bytes** | identity 8, heat 8, TTL 4, link 4, mass 2, flags + behaviour tag 2 | | Header — reserved | **4 bytes** | deliberate slack; see below | | Header — total | **32 bytes** | | | Inline payload | **32 bytes** | a small message fits in one cell — mass 1 | | Per-VM Stadium | ~4096 cells = 256 KB | hundreds of hot words and blocks, ACLs, messages in flight | | Continuation cell | **undetermined** | see below — depends on an unsettled encoding | The four reserved bytes are deliberate rather than a rounding artefact. The fields above sum to 28; padding to 32 keeps the header a clean half-cell and gives the `contains` wire item 1.1 resolved to somewhere to live — a 4-byte index, the same width as `link`. (The header/continuation discriminator does not compete for this space: item 3.1 ruled it an external side bitmap, not a header field — see §3's amendment.) Whether 4 bytes is the final byte count item 3.1 settles on for `contains`, or whether it can shrink, is exactly the kind of thing item 3.1's real byte count settles, not this section — flagged here rather than assumed. Reserved space in a header that is expected to grow is cheaper than repacking one later. 256 KB per VM is comfortable against QEMU's `-m 1024`, and the outer Stadium's capacity (§20.5 #1) then follows from how many VMs the machine is willing to host. #### The continuation cell — RESOLVED by item 1.12, linked **RESOLVED 2026-08-04.** An earlier draft stated a 1024-byte block is "17 cells: 1 header + 16 payload." That figure assumed continuation cells are contiguous and carry nothing but bytes — an assumption this section settles rather than leaves open. **Linked, not contiguous — forced by what §22.3 already decided, not a fresh 50/50 choice.** §22.3's per-VM free list (DECIDED) draws cells individually, with no adjacency guarantee. Guaranteeing contiguous runs for multi-cell patrons would mean changing that allocator to find runs rather than pop a free-list head, which reintroduces exactly the fragmentation §3 and §13 already ruled out by choosing fixed-size, index-linked cells in the first place. The allocator that exists says linked; contiguous would require an allocator that does not. **Consequence for sizing:** each continuation cell carries a 4-byte next-index alongside its payload, leaving **60 bytes usable** out of the 64-byte cell. A 1024-byte block needs **18 continuation cells** at 60 usable bytes each, not 16 at a full 64 — the same figure this section's earlier draft flagged as the linked-form cost without yet choosing it. §23.3's sizing table is now complete on this row. Firmness of each figure: - **Heat at 8 bytes is fixed**, not chosen — Q48.16 in a `uint64_t`, matching `execution_heat_q48` in the existing implementation. - **Link at 4 bytes** caps the Stadium at ~4 billion cells, far past anything plausible. It could shrink to 3 or even 2 bytes if the header gets tight. - **TTL at 4 bytes** gives ~4 billion ticks — over a year at 100 Hz. Almost certainly oversized; 2 bytes may do. - **Cell size 64** is the one to validate first, because everything else is expressed relative to it. **LEANING.** The structure is decided; the constants are not. ### 23.4 Open 1. **Dirty-event granularity** (§23.2) — confirm region-based when console work begins. 2. **Cell size validation.** Build the header for real, count the bytes, and check that a typical message still fits in one cell with the behaviour tag and flags included. 3. ~~**Is `identity` needed at all for every patron kind?**~~ **RESOLVED by item 1.10 (§25.2), 2026-08-04 — no, it cannot be elided.** For a word it is a name; for a block a handle (§24.4); for a message possibly nothing — its identity could be its index. If identity can be elided for some kinds, 8 bytes of a 32-byte header is a large saving. This must not become a per-kind branch (§18.3), so it is only worth doing if it can be expressed uniformly. **It cannot be, and the reason is a genuine conflict, not just difficulty.** Two ways to elide it, both blocked: - **Elide it only for kinds that don't need it** (messages) while keeping it for kinds that do (words, blocks). This is exactly the per-kind branch §18.3 forbids — the engine, or something reading the header, would have to know a message's header is shaped differently than a word's, which reintroduces the type-field problem §3 exists to prevent. - **Elide it everywhere, uniformly.** This breaks the kinds that genuinely need it: a word is resolved by name, not by Stadium position — `vm_dict_resolve_in_bucket()` looks up by name — and a block is resolved by LBN, not by Stadium position either. The Stadium index is not a substitute for either; they are different addressing schemes serving different lookups. So the saving is not reachable without violating either §18.3's uniformity requirement or a lookup mechanism a patron kind already depends on outside the Stadium. `identity` stays a fixed, always-present 8-byte header field for every kind, whether or not a given kind's own logic makes use of it. **Larger than it first appeared.** §3 now declares cells a closed two-valued union — header or continuation. Whatever distinguishes the two occupies header space and interacts directly with any identity elision: a scheme that reuses the identity field as the discriminator, for instance, would couple the two decisions. Settle the header/continuation encoding first; identity elision is downstream of it. (Settled by item 1.12 — see §23.4 #4 — reinforcing that identity stays untouched by that encoding.) 4. ~~**The continuation-cell encoding.**~~ **RESOLVED by item 1.12 — linked.** See §23.3's "The continuation cell" subsection: forced by §22.3's already-decided disjoint free list, not a fresh choice. 4-byte next-index, 60 usable bytes per continuation cell, 18 continuation cells for a 1024-byte block. Item 3.1 is unblocked on this item. --- ## 24. Mutation, identity, and mass stability §17.4 #4 and §19.6 #2 ask whether patrons mutate in place. The question as posed does not survive contact with the patron kinds, and the version that does is cheaper. ### 24.1 Full immutability is not available FORTH-79 blocks are mutable by definition: `BLOCK` returns a buffer, writes go into it, `UPDATE` marks it dirty, `FLUSH` writes it back. In a block editor that is a mutation per keystroke. A rule that every write produces a new patron would mean a new patron per keystroke. Words already behave the opposite way. Redefinition creates a **new** dictionary entry rather than editing the existing one — which is why `vm_dict_resolve_in_bucket()` resolves in reverse-insertion order, newest visible definition winning. The kinds genuinely disagree. Forcing them to agree would be §11's exception trap approached from the other side. ### 24.2 The concern was never payload — it was mass and identity §19.6 #2 asks this for density stability: if mass changes underfoot, density changes and ranking is meaningless. §13 asks it because in-place mutation is what makes proofs expensive. Neither concern is about payload bytes. A block's contents can change entirely and it is still 1024 bytes at the same handle. So the invariant is narrower than immutability and costs almost nothing: | Tier | Rule | |---|---| | **Identity** | never changes for the life of the residency | | **Mass** | never changes *as a side effect of use* | | **Header** — heat, TTL, flags, link | mutates freely; this is the engine's work | | **Payload contents** | may mutate in place, provided size and identity do not | **DECIDED.** ### 24.3 VMs appear to violate the mass rule, and do not A VM's mass is elastic by §22 — that is the point of elasticity. But it changes only through Hera's arbitrated transfer, on the slow loop of §22.4. So the rule is not that mass is constant: > **Mass changes only through an arbitrated transfer, never through traffic.** Traffic moves heat and nothing else. Density is therefore stable between transfers, which is what ranking requires, and §13 gets a mass function that changes at known, enumerable points rather than continuously. ### 24.4 A resident patron's identity is its handle, not its content hash This follows from tier 1 and is worth stating because it is easy to get backwards. A block's identity while resident is its **handle** — its LBN. Its content hash is computed at **migration**, for the warehouse. If identity were the content hash, editing a resident block would change its identity mid-residency and break tier 1 immediately. §3 already permits this: identity is *"handle or name"*. It never said hash. Content-addressing therefore stays where it belongs — at the warehouse boundary, which is already how Artemis and the capsule model behave. The Stadium does not do content addressing; the warehouse does. ### 24.5 The rule this reduces to > **No patron may grow or shrink while resident. If it needs to be a different size, it is > a different patron.** No per-kind branching, no exception, and it holds for all five patron kinds. ### 24.6 Open 1. ~~**What happens to a resident block whose content changes, at migration time?**~~ **RESOLVED by item 1.8 (§25.2), 2026-08-04.** Its new content hash differs from the one it arrived with. The warehouse sees a new block; the Stadium saw one continuous residency. That is coherent, and the hand-off is: the new hash is computed **exactly once, at the migration boundary**, as part of the block's `MIGRATE` code field — consistent with §24.4 (a resident block's identity is its handle/LBN, never its content hash) and §17.2 (migration is the block's departure event, not destruction). Nothing about a resident block's identity changes mid-residency regardless of how many times its content mutates; the hash is a warehouse-side fact computed only when the block actually leaves. **Whether the old hash is retained anywhere for audit is out of scope here.** §5 draws this boundary already: the warehouse is beneath the Stadium, and the Stadium does not do content addressing — the warehouse does. Audit retention is an Artemis-layer policy question, not a Stadium one, and inventing an answer for it here would cross that boundary rather than respect it. 2. ~~**Does redefining a word while its old definition is resident leave two patrons?**~~ **RESOLVED by item 1.9 (§25.2), 2026-08-04 — confirmed, yes.** Not a design choice, a factual check: `vm_dict_resolve_in_bucket()` (`dictionary_management.c:257`) walks a bucket chain and returns the newest match — "the newest visible definition wins (FORTH-79 shadowing)" (`:266`). Nothing in the redefinition path unlinks or frees the superseded `DictEntry`; it stays in the bucket, merely shadowed. So if both the old and new definitions are hot, both are correctly on the floor, both have mass, both are ranked independently. This is the right behaviour, not an artefact to work around — they are genuinely two different words with two different execution histories. --- # 25. The punch list **This section is authoritative for what is done and what is not.** Sections 1–24 are the design. This is the work. --- ## 25.0 How to implement this punch list **Read this subsection every time before touching an item. Do not skip it because it was read earlier in the session.** ### The rules 1. **One item at a time.** Take the lowest-numbered unchecked item whose prerequisites are met. Finish it completely. Do not begin a second item while one is in progress. 2. **Do not jump ahead.** Do not start a later item because it seems easy, related, or convenient. Do not do "while I'm in here" work. If a later item looks like it should be reordered, say so and wait for an answer — do not reorder unilaterally. 3. **Do not increase scope.** Do exactly what the item says. If the item says "write the trap entry," write the trap entry — not the trap entry plus a refactor of the file it lives in. Anything you notice that is not in the item gets **reported**, not fixed. This includes obvious bugs. Report them; they get their own item if they warrant one. 4. **Do not fabricate, confabulate, or conflate.** If you do not know how something works, read it. If you cannot determine it by reading, **stop and say so**. Never invent a function, a register name, a constant, a FORTH word, or an API that you have not verified exists in this tree or in the relevant hardware manual. Never guess at a value and present it as known. Never merge two things that are similar into one thing that is neither. A wrong answer stated confidently has cost this project git resets before. 5. **When blocked, stop.** Report exactly what is blocking, what was tried, and what is needed. Do not work around it silently. Do not substitute a different approach and carry on. 6. **Acceptance is not optional and not negotiable.** Each item states *Done when*. An item is not done until that exact condition is met and observed. Not "should work," not "compiles cleanly" unless that is what the item says. If acceptance requires the three-architecture QEMU boot, then all three have booted and their logs exist. 7. **Report failures honestly.** If a test fails, say it failed and show the output. If a step was skipped, say it was skipped and why. Never describe partial work as complete. ### The commit discipline Every checked-off item gets **its own commit**, and that commit contains: - the code or document change for that item, and - this file, with that item's checkbox changed from `[ ]` to `[x]`. Nothing else. One item, one commit. The punch list and the tree move together, so the document is never a claim about work that is not in the branch. Commit message format: ``` : e.g. riscv64: real trap entry with SRET return Punch list §25 item complete. Co-Authored-By: ``` ### Standing constraints from `.claude/CLAUDE.md` These override anything convenient: - **Never create a branch without explicit permission.** Work on the branch you are on. - **Never stash.** If the tree is dirty, report it and wait. - **Never apply a fix that was not requested.** Report it instead. - **Acceptance for any kernel change is the three-architecture QEMU boot.** There is no other test. The hosted `make` build is compile-sanity only. - **One QEMU instance at a time, foreground, `clean` before `qemu`.** Concurrent runs corrupt the timing signal. - **Read `experiments/bare_metal/README.md` in full before editing any `.4th` file**, and verify capsule edits with `mkcapsule --lint` rather than counting bytes by hand. ### When an item is genuinely wrong The design is not sacred. If implementing an item shows the design is wrong, **stop, report what the code demonstrated, and propose the amendment.** Amend the relevant section of this document first, get agreement, then continue. Do not implement something you believe is wrong because it is written down, and do not silently implement something different. --- ## 25.1 Phase 0 — Substrate *Nothing in later phases can start until Phase 0 is complete. The engine has nothing to run on until there is a tick on all three architectures (§16.1, §16.5).* - [x] **0.1 — Prune `capsules/init.4th` to Hera alone.** **Delete** blocks 2051, 2052, 2053, 2054, 2055, 2056, 2058, 2059 — the readiness handshake, broadcast test, TRIPOD-TEST, HERMES-E2E, and fleet-DoE scaffolding. **Edit** the three surviving blocks: 2057 (`BOOT-BANNER` — drop the Tripod lines), 2049 (remove the Artemis and Hermes births with their `CD-INIT` calls and the `common:msg.4th` / `process.4th` loads; keep `lib.4th`; adjust `VM-TREE` / `VM-CHILDREN`), and 2050 (keep the `BOOT-BANNER` call; remove the `READINESS-HANDSHAKE` and `BROADCAST-TEST` calls). Leave `capsules/hermes/` and `capsules/artemis/` untouched on disk. An earlier draft of this item said "remove blocks 2050–2059," which contradicted its own Refs line — 2050 survives, edited (C1). *Done when:* all three architectures boot to the prompt with Hera alone, no Hermes or Artemis in the banner, and the three logs exist under `logs/`. *Refs:* the surviving blocks are 2057, 2049, 2050. `mkcapsule --lint` before building. - [x] **0.2 — riscv64: real trap entry.** Replace the one-way `riscv64_trap_entry` in `arch/riscv64/isr.S` with save / dispatch / restore / `sret`. Route `scause` bit 63 + cause 5 to the timer path; everything else keeps falling through to the existing fatal handler. **FP state is not optional (B2 verified):** the kernel builds `-march=rv64gc -mabi=lp64d` (`Makefile.starkernel:162`) — hard-float ABI, and kernel code genuinely uses doubles (`hotwords_stats_print`). The trap entry must save the ABI's caller-saved FP registers plus `fcsr` alongside the integer set; verify the exact register list against the RISC-V psABI, not this document. Do not "fix" this by switching to soft-float — that breaks existing code and is a build-system decision nobody has made. *Done when:* riscv64 boots to the prompt with no regression, and exceptions still halt with the same diagnostic as before. **No trap source exists yet at this item** — the timer arms in 0.3, whose tick-advance acceptance is what proves this entry path took and returned an interrupt (C2). Do not arm the timer early to manufacture evidence here. - [x] **0.3 — riscv64: SBI timer and real time base.** **First, the prerequisite this item silently assumed (B1 verified it absent):** the kernel has no DTB access — `BootInfo` (`uefi.h:624-639`) carries no FDT pointer and no FDT code exists in the tree. Capture the DTB pointer from the EFI configuration table (DTB table GUID) into a new `BootInfo` field in the shared loader. This also serves 0.6. Then: arm the timer via the SBI TIME extension, **enable `sie.STIE`**, and **re-arm inside the handler on every tick — the SBI timer is one-shot by nature, and a missed re-arm stops the heartbeat forever with no error. That is the single most likely silent failure of this item (C3).** Switch the time base from `rdcycle` to the `time` CSR and take its frequency from the device tree `timebase-frequency`, with a named fallback constant — not a bare magic number (§16.2). *Done when:* `heartbeat_ticks()` advances on riscv64 and the tick interval matches the configured rate within measurement noise. Verify the SBI extension is present before relying on it; if it is absent, stop and report rather than falling back silently. - [x] **0.4 — aarch64: determine the exception level at runtime.** Read `CurrentEL` once, early, and let it govern **everything EL-dependent**, not just the timer (B3): the vector base register (`VBAR_EL1` vs `VBAR_EL2` — today's `isr.S` writes `VBAR_EL1` unconditionally, which is never consulted for exceptions taken at EL2), the saved-state pair (`ELR_ELx`/`SPSR_ELx`), and the timer register set (`CNTP_*_EL0` vs `CNTHP_*_EL2`). Do not hardcode either level anywhere. *Done when:* the boot log states which EL was detected, on real QEMU output. - [x] **0.5 — aarch64: IRQ vector split.** Split `irq_spx` out of the shared fatal handler in `arch/aarch64/isr.S`: save `x0`–`x30` plus the saved-state registers (see B3 note below), call a C handler, restore, `eret`. The other fifteen vectors are unchanged. Note the 128-byte slot limit — the save sequence will not fit inline and must branch to a trampoline. **FP state is not optional (B2 verified):** the kernel builds without `-mgeneral-regs-only` (`Makefile.starkernel:146`), so the compiler may use SIMD registers anywhere. Save the ABI's caller-saved SIMD set plus `FPSR`/`FPCR` alongside the integer set; verify the exact list against the AAPCS64, not this document. **EL governs the whole path (B3):** this item previously hardcoded `ELR_EL1`/`SPSR_EL1`, while 0.4 refuses to hardcode the EL — and today's `isr.S` installs `VBAR_EL1`, which is never consulted for exceptions taken at EL2. The EL detected in 0.4 must select the vector base register (`VBAR_ELx`), the saved-state pair (`ELR_ELx`/`SPSR_ELx`), and the `eret` target state, not just the timer registers. *Done when:* aarch64 boots to the prompt with no regression. **No IRQ source exists yet at this item** — the GIC lands in 0.6 and the timer arms in 0.7, whose tick-advance acceptance is what proves this path took and returned an IRQ (C2). Do not pull 0.6/0.7 work forward to manufacture evidence here. - [x] **0.6 — aarch64: minimal GICv2.** Enable the distributor and CPU interface, set the priority mask, enable the timer PPI, acknowledge via `IAR` / `EOIR`. **Read the base addresses and the PPI INTID from the device tree — do not take them from memory or from this document.** The DTB pointer comes from the `BootInfo` field added in 0.3 (B1 verified no such field existed). If the DTB turns out to be unreachable on aarch64 EDK2, stop and report — deciding between loader work and named QEMU-virt constants with a recorded caveat is Captain Bob's call, not the implementer's. > **DTB confirmed unreachable (checked live, 2026-08-03) — same finding as riscv64.** > `fdt_valid(boot_info->dtb)` fails on this system's aarch64 build too (installed > firmware: `qemu-efi-aarch64` 2025.11-3ubuntu7, no alternate available). Ruling: named > QEMU-virt constants, verified rather than recalled — `qemu-system-aarch64 > -machine virt,dumpdtb=...` was used to dump QEMU's own internal devicetree (the one > EDK2 fails to forward) and decoded with this tree's own `fdt.c` reader, giving > **GICD 0x08000000, GICC 0x08010000** (both confirmed for this exact QEMU 10.2.1 > build, not assumed stable across versions) and **PPI 30** for the non-secure EL1 > physical timer (bonus finding: PPI 26 for the EL2 hypervisor timer, for item 0.7's > EL2 path). Register *offsets* (GICD_CTLR, GICC_IAR, etc.) are architectural, not > board-specific, and were cross-checked against > `/usr/src/linux-headers-*/include/linux/irqchip/arm-gic.h` rather than recalled. > **Acceptance corrected — same defect C2 already fixed for items 0.2 and 0.5, missed > here.** "The timer interrupt is delivered and acknowledged" cannot be observed within > this item's own scope: nothing arms the timer until item 0.7's `apic_timer_start()`. > As written this item could never be marked done on its own evidence. Acceptance is > now the same shape as 0.2/0.5: GIC initialises without fault, IAR/EOIR path is wired > into `aarch64_irq_handler()` and ready, boots with no regression. Item 0.7's > tick-advance acceptance is what proves this path actually delivers and acknowledges an > interrupt, exactly as 0.5 already defers to 0.7 for the same reason. *Done when:* GIC distributor and CPU interface initialise without fault; the timer PPI is enabled; `aarch64_irq_handler()` reads `IAR`, dispatches, and writes `EOIR`; boots to the prompt with no regression. Scope is one interrupt; a general GIC driver is out of scope and must not be written. - [x] **0.7 — aarch64: arm the generic timer.** `apic_timer_start()` / `apic_timer_stop()` using the register set chosen in 0.4, re-armed each tick. *Done when:* `heartbeat_ticks()` advances on aarch64 at the configured rate. - [x] **0.8 — Converge the three architectures on one tick path, and make the physical heartbeat adaptive.** One `heartbeat_tick()` call site per architecture; the ISR does counter, timestamp and flag only. **Per the GAP-A1 ruling, the hardware tick drives instrumentation only:** the bottom half services TIME-TRUST bookkeeping, and the engine (`vm_tick()`, decay, inference) stays on the virtual tick — execution-paced, exactly as today. Nothing that feeds patron state reads the hardware counter. **Per §26 (ruled):** the physical re-arm period is no longer a fixed 100 Hz constant. `heartbeat.c` owns the current adaptive period (`heartbeat_set_adaptive_period_ns()` / `heartbeat_next_period_ns()`); Loop #7's existing site in `vm_runtime.c` calls the setter with its stable/volatile-derived value, rescaled to the 10 ms kernel base per §26.3 (not the 10 µs hosted `HEARTBEAT_TICK_NS`); each architecture's re-arm function reads the getter and converts to its own raw counter units instead of using a hardcoded period. No new concurrency primitive — single writer (mainline), single reader (ISR), same shape §21.1 already found free on one hart. *Done when:* all three architectures drive the same TIME-TRUST bottom half; no loop math runs in interrupt context; `vm_tick()`'s call sites are unchanged. > **Live variation not directly observed.** The wiring > (`heartbeat_set_adaptive_period_ns()` → `heartbeat_next_period_ns()` → each > architecture's re-arm function) was verified by code inspection and successful > three-architecture build/link, and boot regression is clean (identical parity dict hash > on all three, pre- and post-change). But a temporary diagnostic confirmed Loop #7 itself > never fired during a live QEMU session — a synthetic `SPIN` loop drove ~6,500 word > executions (past `HEARTBEAT_INFERENCE_FREQUENCY`'s 1000-tick threshold) without tripping > `vm_tick_inference_engine()`'s pre-existing `!vm->rolling_window.is_warm` gate > (`vm_runtime.c:583`). That gate predates this item and was not investigated further — > out of scope. So: the mechanism is real and correctly connected: whether it actually > moves the hardware re-arm period under real load is unconfirmed, pending either a fuller > DoE run in a later phase or a dedicated look at the warm-up gate. *Refs:* §16.4 (as ruled), §18.4, §21.2, §26. - [x] **0.9 — Write the concurrency constraint at the mutex stub.** Add a comment at `src/starkernel/vm/host/shim.c:415` stating that the no-op is correct only while nothing in interrupt context mutates shared structure, and that making it a real spinlock would deadlock a single hart. *Done when:* the comment is in place. This is a documentation item; no behaviour changes. *Refs:* §21.2, §21.5 #1. - [x] **0.10 — Phase 0 acceptance.** Full three-architecture QEMU run. Confirm: boots to prompt on all three; tick count non-zero on all three; on riscv64 after 0.3, trust near `Q48_ONE` and variance small relative to the new `expected_delta` — not merely "sane", which is unfalsifiable (C6); amd64 output unchanged from its pre-branch behaviour (a valid control under the GAP-A1 ruling, since 0.8 no longer touches engine plumbing). **Then boot one architecture twice and confirm the parity dict hash is identical across runs.** If it drifts, something is firing on wall time and Phase 0 is not complete. *Done when:* all of the above observed, logs committed. > **Observed 2026-08-04.** All three boot to `ok>`. Tick count at the point just before > `sk_repl()` (a bounded wait for 3 real ticks was added at `kernel_main.c` — with none, > the count landed on 1 (amd64) and 0 (riscv64) purely from how little wall time elapses > between arming the timer and this print, which is not the same claim as "the heartbeat > doesn't tick" and would have been a false negative to report as one): amd64 4, > riscv64 3, aarch64 3. riscv64: `trust=0x00010000` (exactly `Q48_ONE`), `variance=0x0`. > amd64: `dict_hash=0x3d4e1daf289da94f`, identical to the pre-item-0.8 baseline > (`logs/20260803-231322`) — unchanged output, as the GAP-A1 control requires. Two > consecutive amd64 boots (`logs/20260804-001948`, `logs/20260804-002021`) both produced > `dict_hash=0x3d4e1daf289da94f` — reproducible, no wall-clock leakage into patron state. > Logs committed: `logs/20260804-001727` (amd64), `logs/20260804-001805` (riscv64), > `logs/20260804-001850` (aarch64), `logs/20260804-001948` / `logs/20260804-002021` > (amd64 double-boot pair). *Refs:* §16.4, §18.5. --- ## 25.2 Phase 1 — Design questions to settle on paper *These need answers, not code. Each one is settled by amending the relevant section of this document and committing that amendment as its own item.* - [x] **1.1 — Exclusive access ("sitting in a car").** §8 asserts a per-patron exclusivity primitive that is not a global lock. Nothing defines it. Decide what it is, what it blocks, and what happens if a patron is selected for reaping while held. *Refs:* §8. **This is the largest unresolved design question.** > **Hard prerequisite of item 3.1, and its outcome may amend §3.** This item is filed in > Phase 1 alongside questions that have no structural effect, and it is not in that class. > A per-patron exclusivity primitive plausibly needs a held flag or a holder index — a > **ninth wire** in §3's table, in the header item 3.1 builds. Resolve 1.1 after 3.1 and > the cell header gets rebuilt. > > §25.4 already blocks Phase 3 on items 1.1–1.7, so the ordering is right. What was > missing is *why 1.1 specifically* — which is the kind of omission that gets an item > quietly reordered later by someone who does not know what it was holding up. §23.3 > reserves 4 header bytes partly against this outcome. > **RESOLVED 2026-08-04 — containment, not a lock.** A ninth wire, `contains` (§3): an > index to the patron currently held inside this one, or none. Reap is **gated**, not > density-derived — a patron with a non-none `contains` link cannot be reaped, full stop. > Chains up to a depth cap, **default 5, exposed as a Kconfig symbol** (named at > implementation time in item 3.1) rather than hardcoded — this project's existing > tunable-knob convention. Unwinding is innermost-first, forced by the chain's own > topology, not a policy choice — no FIFO/LIFO decision exists to make. Single occupant per > level. Full argument in §8. **Same-Stadium relationship** — distinct from item 1.7's > VM-tree-recursion question (§20.5 #4), which this does not resolve and remains open. > Item 3.1 is now unblocked on this item; the ninth wire and the reserved header bytes > (§23.3) are the concrete carry-forward. - [x] **1.2 — The resting floor.** Whether a VM's quota has a floor, and whether it is the mass of its pinned patrons (derived) or a constant (tuned). *Refs:* §22.5 #1. > **RESOLVED 2026-08-04.** Floor = max(mass of pinned patrons, one message-sized cell). > Both terms derived, neither tuned — the second closes a reachability gap the first term > leaves open for a VM with nothing pinned (zero quota means it can never receive the > message that would let it regrow). Full argument in §22.5 #1. - [x] **1.3 — What triggers a capacity transfer.** Hera arbitrates; on what signal, and how often. Should read the density gradient, not a schedule. **Constraint, not optional:** arbitration mutates patron mass, so §18.5's invariant binds it directly — *anything that influences patron state advances on tick count; wall-clock time may be recorded for diagnostics and must never be an input to a decision.* Pacing arbitration off a wall-clock interval would reintroduce exactly the defect item 2.1 exists to remove, in a new place. Whatever 1.3 decides must be expressible in ticks. *Refs:* §22.5 #2, **§18.5**, §22.4. > **RESOLVED 2026-08-04.** Hera evaluates once per capacity-tick (a coarser, derived > multiple of the virtual tick — the multiple itself is item 1.4). At each capacity-tick > she finds the single densest and single least-dense live VM; if they differ at all, a > transfer is eligible — a pure comparison, no tuned threshold, same shape as §19.3's > admission rule. Cadence carries the "sustained density" requirement; the decision itself > is a comparison. Both are tick-expressible, never wall-clock. **Not resolved:** how much > capacity moves per transfer — reported, not invented, out of this item's scope. Full > argument in §22.5 #2. - [x] **1.4 — The heat/capacity timescale ratio.** The ordering is fixed (capacity slower); the ratio is not. *Refs:* §22.4, §22.5 #3. > **RESOLVED 2026-08-04 — 1000:1.** The capacity-tick gets its own named constant, > defaulted to 1000 virtual ticks, matching the existing precedent at > `capsule_vm_physics.c:434-441` (`vm_physics_heartbeat_tick()`'s > `HEARTBEAT_INFERENCE_FREQUENCY`-gated fleet recalibration loop) rather than an invented > number. Comfortably past §12 Q5's order-of-magnitude minimum. Kconfig-tunable at > implementation (item 3.1). Full argument in §22.4. - [x] **1.5 — The outer bound.** The outer Stadium's capacity, and the behaviour at the bound: birth refused, or coldest VM reaped. *Refs:* §20.5 #1, §22.5 #4. > **RESOLVED 2026-08-04.** Bound = 4 (matches Tripod's known topology: Hera + 2 Hermes + > Artemis), Kconfig-tunable, explicitly a placeholder pending a later DoE campaign to find > an idealized default rather than a second guess made on paper. Fixed for the machine's > lifetime once set at build. At the bound, **birth is refused** — not coldest-VM-reaped, > which the original text called "consistent with §19.3" but which does not survive > §20.2's cold-start birth rule (a newborn can never out-density an existing warm VM). > Making room stays Hera's own deliberate act. Full argument in §20.5 #1. - [x] **1.6 — A VM's mass: allocated share, or one cell.** *Refs:* §20.4, §20.5 #2. > **RESOLVED 2026-08-04 — allocated share.** Less a fresh choice than a confirmation of > what §22's elasticity mechanism and §24.3 already treated as decided, and what items > 1.2–1.5 already assumed. The one-cell alternative would make outer-level density > collapse to heat alone, discarding the starved-vs-small diagnostic that's the entire > point of §20.4. Full argument in §20.4. - [x] **1.7 — Rule out recursion beyond two levels** — deliberately, not by omission. *Refs:* §20.5 #4. > **RESOLVED 2026-08-04 — Kconfig-tunable cap, default 2.** Not a hard prohibition: > bounded, same treatment as item 1.1's containment depth. 2 matches §21's already-decided > two-level structure (outer VM Stadium, per-VM inner Stadium). Enforced explicitly at > VM-birth time — a birth that would exceed the configured depth is refused. Full argument > in §20.5 #4. - [x] **1.8 — Block content change at migration.** A resident block whose content changed has a different hash on the way out. State the hand-off. *Refs:* §24.6 #1. > **RESOLVED 2026-08-04.** New hash computed exactly once, at the migration boundary, as > part of the block's `MIGRATE` code field — the resident identity (handle/LBN) never > changes mid-residency. Whether the old hash is retained for audit is an Artemis-layer > question, out of scope for the Stadium per §5's boundary. Full argument in §24.6 #1. - [x] **1.9 — Redefined words as two resident patrons.** Confirm both may be on the floor. *Refs:* §24.6 #2. > **RESOLVED 2026-08-04 — confirmed, yes.** Factual, not a design choice: > `vm_dict_resolve_in_bucket()` keeps both entries resident with newest-wins shadowing, no > GC on redefinition. Correct behaviour, not an artefact. Full argument in §24.6 #2. - [x] **1.10 — Identity elision.** Whether identity can be dropped for some kinds without a per-kind branch. Optimisation; may be closed as "no". *Refs:* §23.4 #3. > **RESOLVED 2026-08-04 — closed as no.** Eliding it per-kind reintroduces the type-field > branch §18.3 forbids; eliding it uniformly breaks lookups words and blocks already > depend on outside the Stadium (name, LBN). Neither path is reachable without violating > an existing constraint. `identity` stays a fixed, always-present 8-byte field for every > kind. Full argument in §23.4 #3. - [ ] **1.11 — Dirty-event granularity.** Leaning region-based. **Blocked on item 4.3** — it is settled as part of the console migration, not speculatively before it (C5). *Refs:* §17.5, §23.2, §23.4 #1. - [x] **1.12 — The continuation-cell encoding.** Contiguous (continuation cells are pure payload; allocation must find runs, reintroducing fragmentation) or linked (each continuation cell carries a next-index, costing 4 bytes of payload and changing every large patron's mass). §22.3's per-VM free list guarantees no adjacency, so linked is the default unless allocation changes. This was §23.4 #4 — a stated blocker of item 3.1 that was never a schedulable item until now (C4). Settling it completes §23.3's sizing table. *Refs:* §23.4 #4, §23.3, §22.3. **Prerequisite of 3.1.** > **RESOLVED 2026-08-04 — linked.** Forced, not chosen: §22.3's disjoint per-VM free list > gives no adjacency guarantee, and guaranteeing contiguity would reintroduce the > fragmentation §3/§13 already ruled out. 4-byte next-index, 60 usable bytes per > continuation cell, 18 continuation cells for a 1024-byte block. §23.3's sizing table is > complete; item 3.1 is unblocked on this item. Full argument in §23.3's "The continuation > cell" subsection. --- ## 25.3 Phase 2 — Prepare the existing physics - [x] **2.1 — Restate heat transfer on the virtual tick.** `vm_physics_touch()` scales transfers by wall-clock elapsed time (`capsule_vm_physics.c:272`). Restate it on **the virtual tick** — the execution-derived counter of §16.4 as ruled, not the hardware heartbeat, whose interleaving with execution is wall-clock-dependent and would leave the acceptance below unachievable (§25.7.1 GAP-A1). *Done when:* no wall-clock value influences heat, **and the same capsule booted twice produces an identical fleet heat sum across the two runs** — achievable now that both the touch points and the elapsed-tick values are deterministic functions of execution. *Refs:* §16.4 (as ruled), §18.5, §19.6 #3. > **Acceptance corrected.** This item previously accepted on the dictionary-hash > double-boot check from 0.10. That cannot detect this work: §18.5 establishes that > `vm_physics_touch()` writes `node->physics`, **not** `DictEntry.execution_heat`, and > therefore never reaches the parity hash. The dict hash would be identical whether 2.1 > succeeded, failed, or was skipped. Fleet heat is the quantity this item changes, so > fleet heat is what has to be compared. Run 0.10's dict-hash check as well, as a > regression guard — but it is not evidence for 2.1. > **DONE 2026-08-04.** `vm_physics_touch()` no longer takes a `now_ns` parameter at all — > it reads `fleet_heartbeat_tick_count` internally, which `vm_runtime.c:143` confirms is > execution-paced (advanced once per `vm_tick()` call), not wall-clock. `VMPhysics.last_active_ns` > → `last_active_tick`; `VMFleetTouchSample.elapsed_us` → `elapsed_ticks`; a new explicit > `touched` flag replaces the old `> 0` sentinel, which doesn't safely carry over to tick > counts (a genuine first touch can land on tick 0). The three call sites (VM-EXEC, > VM-CALL, VM-STEP in `mama_forth_words.c`) dropped `vm_monotonic_ns(vm)` accordingly. > `vm_physics_heartbeat_tick()`/`vm_physics_tick()`'s own dead `now_ns` parameters were > left alone — already unused, already documented as such, out of this item's scope. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-0.10 baseline, on amd64 (×2), aarch64, > and riscv64. Logs: `logs/20260804-113146`, `logs/20260804-113233` (amd64 pair), > `logs/20260804-131020` (aarch64), `logs/20260804-131119` (riscv64). No new compiler > warnings in the touched files. > > **Honest limitation on the fleet-heat-sum acceptance criterion.** With Tripod pruned to > Hera alone (item 0.1), `vm_physics_touch()`'s fan-out has no other live VM to pull heat > from — `others_total` is always 0, so the fleet heat sum is trivially `Q48_ONE` on every > boot regardless of whether the tick logic is correct. The double-boot dict-hash match is > a valid regression guard (as the acceptance note above already says), but it does not > actually stress-test this item's new code path. A real check needs at least one other > live VM to touch, which returns in Phase 4 (Hermes/Artemis) — not fabricated here. > > **Unresolved, flagged not fixed:** `fleet_transfer_slope_q48`'s seed (65536/3) was > calibrated against elapsed wall-clock microseconds; elapsed ticks between touches is a > different quantity at a different scale, and the seed has not been re-fit against it. > Left as-is per §25.0 rule 4 (no invented numbers) — a real re-tune is DoE work (item > 5.1), and can only be meaningfully measured once Phase 4 restores a multi-VM fleet > anyway, per the limitation just above. - [x] **2.2 — Bound the VM registry.** The registry is a `kmalloc`-backed unbounded list (`capsule_vm_physics.c:71-72`). Give it the hard bound decided in 1.5. *Done when:* the population is bounded, birth at the bound behaves as 1.5 specifies, and the three-architecture boot is unaffected. *Refs:* §2, §13, §19.2, §20.2. > **Justification corrected.** This item previously read that the registry "makes fleet K > an identity that cannot fail" and accepted on `VM-CONSERVED?` becoming able to fail. > Both were wrong, and the reason is now in §20.2: heat is **transferred**, not > renormalised, so conservation is already a real invariant and already falsifiable — > by the dropped-remainder path at `:240-244` and by integer truncation at `:304-305`. > Bounding the population changes neither. > > The bound is still needed, on the two grounds §2 now states: **finite state** for §13's > induction and model checking, and **density requires a capacity to be dense within** > (§19.2), without which §19.3's admission rule has nothing to compare against. Those are > the honest justifications and this item now rests on them. > > Making conservation *more* falsifiable is a different and larger piece of work — fixing > the truncation leak — and is recorded in §25.7 rather than folded in here. > **DONE 2026-08-04.** `STADIUM_MAX_VM_COUNT` Kconfig symbol (default 4, per item 1.5), > wired through `Makefile.starkernel` and given the missing `starforth_config.h` fallback > default (`STARFORTH_CONFIG_STADIUM_MAX_VM_COUNT_DEFAULT`) that the earlier WIP commit > omitted — without it the macro was only ever defined when a Kconfig `.config` was active, > and this build has none, so the first compile attempt failed with `STADIUM_MAX_VM_COUNT` > undeclared. `vm_registry_live_count()` (added in the prior WIP commit, counts only > `VM_STATE_LIVE` nodes) is now called in `capsule_birth_baby()` > (`src/starkernel/capsule/capsule_birth.c`), between capsule validation and > `vm_registry_alloc()`, so a full fleet is refused — returning the new > `CAPSULE_RUN_ERR_FLEET_FULL` and logging via `capsule_parity_log_birth_failed()` with > `vm_id=0` (no VM is allocated on this path) — before any EMBRYO registry slot is > consumed. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-0.10/2.1 baseline: amd64 > (`logs/20260804-134914`), aarch64 (`logs/20260804-134957`), riscv64 > (`logs/20260804-135102`). > > **Honest limitation.** With Tripod pruned to Hera alone (item 0.1), the fleet never > reaches `STADIUM_MAX_VM_COUNT` during boot, so this run exercises the bound-check code > path only in the trivially-not-full case — the actual refusal branch is unexercised until > Phase 4 restores a multi-VM fleet. Same caveat item 2.1 already recorded for the same > reason. --- ## 25.4 Phase 3 — Stadium core *Blocked on Phase 0 complete, and on items 1.1–1.7 and 1.12.* - [x] **3.1 — Cell and header.** Define the entry with all eight wires (§3) — **nine if item 1.1 resolves to a holder index.** Define both members of §3's closed two-valued union: patron header and continuation cell. Validate the 64-byte cell by counting real bytes; adjust and record if it does not fit. **Blocked on:** item 1.1 (may add a wire) and item 1.12 (the continuation-cell encoding — contiguous or linked — which sets the mass of every large patron and cannot be guessed). *Refs:* §3, §23.3, §23.4 #4. > **DONE 2026-08-04.** `StadiumPatronHeader` and `StadiumContinuationCell` defined in the > new `include/starkernel/vm/stadium.h`, unioned as `StadiumCell`; translation unit > `src/starkernel/vm/stadium.c` added to `Makefile.starkernel`'s `LOADER_EXTRA_SRCS` / > `KERNEL_EXTRA_SRCS` so the header actually gets compiled, not merely included by > something that never builds. > > **Discriminator ruling, made before this item's code was written (Captain Bob's call):** > the header/continuation discriminator is an external side bitmap, one bit per cell, kept > outside the 64-byte cell array — not a header field. Amended into §3 and §23.3 > accordingly. Item 3.1 declares the bitmap's purpose and indexing contract in a comment; > it does not allocate it — that is item 3.2's scope, since sizing depends on the memory > budget item 3.2 works from. > > **Byte count, both counted for real, both exactly 64 with zero compiler-inserted > padding** (verified via three C99-portable negative-array-size assertions, no > `_Static_assert` — this project targets C99, not C11): > - `StadiumPatronHeader`: `identity` u64(8) + `heat` u64(8) + `ttl` u32(4) + `link` u32(4) > + `contains` u32(4) + `mass` u16(2) + `flags` u8(1) + `behaviour` u8(1) + > `payload` u8[32] = 64. `pin` lives as bit 0 of `flags`, not its own field, matching > §3/§23.3. Fields ordered largest-to-smallest so every offset is already a multiple of > its own alignment and the 64-byte total is a multiple of the struct's 8-byte max > alignment — no padding, without resorting to a `packed` attribute (a GNU extension, > forbidden by CLAUDE.md's strict-ANSI-C99 rule). > Because the discriminator moved outside the cell, this matches §23.3's original > 32-byte-header / 32-byte-inline-payload proposal exactly — no adjustment needed here, > unlike the continuation cell below. > - `StadiumContinuationCell`: `next` u32(4) + `payload` u8[60] = 64, unchanged from > item 1.12's figure — the discriminator living outside the cell means neither variant's > byte budget was disturbed by it. > > **Assertion proven live, not just present:** temporarily changed the header check's > expected size to 63, recompiled `stadium.c` standalone > (`cc -std=c99 -Wall -Werror -Wextra -D__STARKERNEL__`), confirmed the build failed with > `error: size of array 'stadium_header_size_check' is negative`, then restored it and > confirmed a clean compile. > > **Regression: clean, and `stadium.o` confirmed present.** All three architectures boot to > `ok>` with identical `dict_hash=0x3d4e1daf289da94f`, matching the item-2.2 baseline — > amd64 (`logs/20260804-145132`), aarch64 (`logs/20260804-145232`), riscv64 > (`logs/20260804-145329`). `find build/ -iname stadium*` confirmed > `obj/loader/vm/stadium.o` and `obj/kernel/vm/stadium.o` both exist post-build, closing > the gap the WIP on item 2.2 exposed: a header nothing compiles proves nothing. > > **Open, deferred honestly:** §23.4 #2 ("does a typical message still fit in one cell") > remains unanswered — there is no message patron struct anywhere in this tree yet > (messages are undesigned future work), so there is nothing concrete to check the 32-byte > inline payload against. Not fabricated a number to close this; left open. > > **REOPENED 2026-08-04.** While reading context for item 3.2, found that two earlier > resolutions had explicitly named *this* item as where their Kconfig symbols would be > implemented — item 1.1 (line ~2400, "exposed as a Kconfig symbol, named at implementation > time in item 3.1", the `contains`-chain depth cap, default 5) and item 1.4 (§22.4 and its > own resolution, "Kconfig-tunable at implementation (item 3.1)", the capacity-tick > constant, default 1000). Neither made it into the work above, because 3.1's own stated > scope ("cell and header") never mentioned them — the promise lived only in items 1.1 and > 1.4's text. Captain Bob ruled: reopen, add both here (Phase 3 is implementation, unlike > Phase 1's paper-only items — item 1.7's own nesting-depth Kconfig symbol was correctly > left undone at Phase 1, by contrast). Declaration only, matching how > `STADIUM_MAX_VM_COUNT` was introduced in item 2.2's WIP commit before its consuming logic > existed: `STADIUM_CONTAINS_DEPTH_MAX` (default 5) has no consumer yet — reap-gating on > `contains` is item 3.5's scope. `STADIUM_CAPACITY_TICK` (default 1000) has no consumer > yet either — capacity arbitration isn't on the punch list at all yet. Not inventing that > logic here; only the two symbols. > > **RE-CLOSED 2026-08-04.** Both symbols added following `STADIUM_MAX_VM_COUNT`'s exact > pattern: `Kconfig.kernel` entry, `Makefile.starkernel` `kconfig_int` + > `VM_FEATURE_FLAG_VARS` forwarding, `starforth_config.h` fallback default (needed for this > no-`.config` build, same gap the original `STADIUM_MAX_VM_COUNT` WIP commit hit and item > 2.2 fixed). `stadium.h` now includes `starforth_config.h` and carries two more > C99-portable compile-time checks (`> 0`, not byte-count) proving both symbols are defined > and sane in the same translation unit as the cell checks. `contains`'s field comment now > references `STADIUM_CONTAINS_DEPTH_MAX` by name. Recompiled `stadium.c` standalone > (clean) before the full run. > > **Regression: clean, re-run after reopening.** All three architectures boot to `ok>` > with identical `dict_hash=0x3d4e1daf289da94f` -- amd64 (`logs/20260804-151032`), aarch64 > (`logs/20260804-151115`), riscv64 (`logs/20260804-151222`). - [x] **3.2 — Boot-time allocation.** One global cell array, sized from the memory budget, before any VM exists. *Refs:* §6, §17.6, §22.3. > **DONE 2026-08-04.** Sizing ruled by Captain Bob among three options (flat Kconfig > constant / runtime PMM-derived / `STADIUM_MAX_VM_COUNT × STADIUM_CELLS_PER_VM`): > **runtime PMM-derived**, matching §17.6 position (b) literally rather than position (a), > the "arbitrary bound" that section argues against. New `STADIUM_MEMORY_PERCENT` Kconfig > symbol (default 1%, ruled by Captain Bob) — `stadium_boot_init()` in `stadium.c` reads > `pmm_get_stats().free_bytes` at the point of allocation, takes that percent, rounds down > to whole `STADIUM_CELL_BYTES` cells. Both the cell array and the discriminator bitmap > item 3.1 declared but did not allocate are `kmalloc`'d here and explicitly zero-filled > (`kmalloc` does not zero — checked `kmalloc.c`, no `memset`). Called from > `kernel_main.c`, immediately before `sk_vm_bootstrap_parity()` — before any VM exists, > per §6. Failure is soft (logs, returns -1, does not halt boot): nothing downstream > consumes the Stadium yet, matching the existing precedent one line below it > (`sk_vm_bootstrap_parity()`'s own failure path also just logs and continues). > > **Made observable, by agreement before writing code** (same blind spot the item-3.1 > uncompiled-header gap exposed): a `Stadium: N cells (M KB)` console line at the > allocation site, so the three-arch boot's serial logs are evidence the array was > actually allocated at the size intended, not just that the kernel still boots. > > **Regression: clean, and the boot line confirmed present on all three.** All three > architectures boot to `ok>` with identical `dict_hash=0x3d4e1daf289da94f`, matching the > item-3.1 baseline. Observed sizes (1% of free memory at the allocation point, this > session's QEMU configuration): amd64 74234 cells (4639 KB, `logs/20260804-164332`), > aarch64 161329 cells (10083 KB, `logs/20260804-164432`), riscv64 76122 cells (4757 KB, > `logs/20260804-164537`). Reported, not evaluated against §23.3's ~4096-cells-per-VM > illustrative figure — that figure is itself labeled a proposal to validate, not a target > this item is scored against. > > **Explicitly not built here, reported per §25.0 rule 3:** per-VM free lists (§22.3, "each > VM holds its own free-list head index into the global array") — those get granted when > Hera assigns a VM its quota, which is not this item's scope. - [x] **3.3 — Behaviour enumeration and dispatch.** Closed tag set fixed at build time. Enumerate behaviours, never patron kinds. *Refs:* §13, §18.3. > **DONE 2026-08-04.** `StadiumBehaviour` (`stadium.h`) enumerates exactly the four tags > §18.3 already names — `MIGRATE`, `DELIVER`, `EXPIRE`, `COOL` — mapped from §17.1's > patron table: blocks→MIGRATE, messages→DELIVER, ACLs→EXPIRE, words and VMs both→COOL > (§18.3 explicitly: "a VM's behaviour tag is COOL, the same tag a word carries"). Nothing > invented — the tag set and mapping were already in the document. > > `stadium_dispatch(cell_index, behaviour)` (`stadium.c`) dispatches on the tag only — > never asks what kind of patron departed, per §3/§18.3. Handlers are stubs (console log > only): the real migrate/deliver/expire/cool actions belong to subsystems not yet > migrated onto the Stadium (Phase 4, §25.5). Nothing calls `stadium_dispatch()` yet > either — item 3.5 is its first consumer. > > **The closedness requirement is now a compiler-enforced property, not just prose:** the > switch in `stadium_dispatch()` is exhaustive with no `default` case. Verified this is > real, not decorative — temporarily deleted the `COOL` case, rebuilt, got > `error: enumeration value 'STADIUM_BEHAVIOUR_COOL' not handled in switch > [-Werror=switch]`, restored it, confirmed clean again. Under this project's > `-Wall -Werror`, a fifth behaviour tag added without updating dispatch is now a build > failure, not a silent gap — the strongest available reading of §13's "closed enumeration, > fixed at build time." > > The header's `behaviour` field stays `uint8_t`, not the enum type itself: C does not > guarantee an enum's underlying type, and that field's offset is load-bearing for the > exact 64-byte layout item 3.1 validated. Documented as holding `StadiumBehaviour` values > cast to `uint8_t`. No new Kconfig symbol — this is a closed code set, not a tunable > number. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-3.2 baseline — amd64 > (`logs/20260804-170226`), aarch64 (`logs/20260804-170309`), riscv64 > (`logs/20260804-170407`). - [x] **3.4 — Density ranking.** Heat ÷ mass, read not computed. *Refs:* §19.2, §19.3. > **DONE 2026-08-04.** `stadium_density(cell_index)` (`stadium.c`) reads a header's `heat` > and `mass` and returns `heat / mass` — a division on demand from fields already stored > in the cell, matching §19.3's "read, not computed by a scheduler" literally: no > background process maintains this value. Stays valid Q48.16 without any special > fixed-point routine, since `heat` is already Q48.16 and `mass` is a plain integer > divisor. > > `mass == 0` and an out-of-range `cell_index` both return 0 rather than dividing by zero > — an empty or never-admitted slot (everything is zero-initialized by item 3.2's > `stadium_boot_init()`, and nothing yet births a patron into the Stadium) has no > footprint to be dense within. > > **Deliberately not built here, per the item's own wording:** finding the densest or > least-dense resident (§19.3's admission/eviction comparison) is item 3.5's scope — this > function supplies the per-cell value that comparison will read, not the ranking/min-max > machinery itself. Nothing calls `stadium_density()` yet either. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-3.3 baseline — amd64 > (`logs/20260804-171332`), aarch64 (`logs/20260804-171414`), riscv64 > (`logs/20260804-171513`). - [x] **3.5 — Admission and eviction.** Admit if denser than the least dense resident. *Refs:* §19.3. > **DONE 2026-08-04.** `stadium_admit(candidate)` and `stadium_evict(cell_index)` in > `stadium.c`. Admission scans for an unused cell first (bitmap bit clear, `mass == 0`) and > places there directly, no comparison needed — §19.3's density rule only governs the full > case. Otherwise finds the least-dense resident, skipping pinned patrons (`flags` bit 0, > §3) and `contains`-gated ones (item 1.1: a patron holding another cannot be reaped), and > evicts it only if the candidate is strictly denser ("denser than," not "at least as dense > as," per §19.3's own wording). Eviction dispatches the departing patron's behaviour > (§18.3) before clearing its slot, per §17.2 ("reap means leaves the floor, not > destroyed"). > > **A real bug caught before this ever ran:** the first draft used `contains == 0` to mean > "holds nothing." Cell index 0 is a valid index — Hera, item 3.6's patron zero — so that > conflated "contains Hera" with "contains nothing." Fixed with a proper sentinel, > `STADIUM_CONTAINS_NONE` (`UINT32_MAX`), distinct from every valid index. Caught by > re-reading before compiling, not by any test. > > **A second-pass review (before the boot run) found one blocking gap, fixed, and two > non-blocking ones, recorded rather than fixed:** > > - **Blocking, fixed:** neither function accounted for `mass`. Admission placed exactly > one cell and set exactly one bit regardless of the candidate's stated mass; eviction > symmetrically freed one cell and orphaned the rest. For `mass > 1` (§23.3: a 1024-byte > block is mass 19) this breaks capacity conservation — cells leak on every eviction of a > multi-cell patron, and the "Stadium is full" test becomes wrong since occupancy was > never correctly accounted. The fix is refusal, not implementation: > **`stadium_admit()` now refuses any candidate with `mass != 1`.** A multi-cell patron > needs its continuation chain allocated through the per-VM free lists (§22.3) — > item 3.2's own DONE note already deferred those as out of scope, granted only when Hera > assigns a VM its quota. This item does not build them; it refuses what it can't yet do > correctly rather than doing it wrong. > - **Not fixed, documented as a live latent gap:** the discriminator bitmap can only say > header-vs-not-header, not free-vs-continuation. The free-cell scan > (`!bitmap_get(i) && mass == 0`) reads offsets 28–29 of whatever cell is actually there > under the *header* struct layout; for a real continuation cell those offsets are > payload bytes, and if they happen to read as zero the scan would treat a live > continuation cell as free and overwrite it. Latent, not live: nothing creates > continuation cells yet, and the `mass != 1` refusal above keeps this provably latent > for as long as that refusal stands. The real fix is the free list itself — a cell is > free iff it is on one, no union-punning needed — which supersedes this scan when built. > - **Not fixed, minor:** `stadium_admit()`'s two full-array scans are O(N) each, and > `candidate_density` duplicates `stadium_density()`'s arithmetic inline because the > candidate is not yet in the array to call it on. Both go away with the free list; not > worth a workaround for code with no caller yet. > > **Unexercised at runtime, stated plainly rather than implied by a passing boot:** nothing > calls `stadium_admit()` or `stadium_evict()` yet (no real patron kind is wired to the > Stadium — Phase 4 migration work). No self-test was added: filling ~74,000+ cells to > actually reach the eviction-on-full branch in a boot run was judged impractical for the > value it would add, following the same honesty precedent item 2.2 recorded for its own > unexercised fleet-full path. The free-cell placement branch, the pin/contains skip > logic, and the density-comparison branch have never executed against real data. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-3.4 baseline — amd64 > (`logs/20260804-172516`), aarch64 (`logs/20260804-172556`), riscv64 > (`logs/20260804-172651`). > > **Amended by item 3.7, 2026-08-04, same day.** `stadium_admit()`'s signature changed — > it now takes a `vm_id` parameter and scopes both free-cell placement and eviction-search > to that VM's own quota, per §22.3's per-VM free lists (built in 3.7, not this item). The > two full-array O(N) scans this item shipped are gone in the O(1)-free-list-pop common > case; the "not fixed, minor" note above about them is superseded. The `mass != 1` > refusal and the pin/contains logic described above are otherwise unchanged. - [x] **3.6 — Hera as patron zero, pinned.** Assert at the eviction site; selecting Hera is a panic, not a filtered candidate. *Refs:* §20.5 #3. > **DONE 2026-08-04.** `STADIUM_HERA_CELL_INDEX` (0) documented as a positional invariant > from §6's boot order (Hera is the first patron admitted), not a runtime identity check — > nothing births anything yet, Hera included, so the index is never actually occupied > today. `stadium_evict()` now panics via `sk_hal_panic()` (already `noreturn`, matching > `arena.c`'s existing use) if a *resident* cell 0 is ever selected. > > **Placement matters and was deliberate:** the assertion runs *before* the pin and > `contains` refusal checks, not after. §20.5 #3 asks for a check independent of pin > holding — if it ran after the pin check, a wrongly-cleared pin would let the ordinary > refusal path quietly return `-1` instead of ever reaching the panic, silently swallowing > exactly the failure this item exists to surface. > > **Per the item's own wording, not implemented as a filter:** `stadium_admit()`'s > least-dense search is unchanged — it still relies on the general pin skip (item 3.5) to > avoid selecting a pinned Hera in the first place. §20.5 #3 explicitly: "state it as an > assertion at the eviction site, not as a filter on the candidate set — filtering hides > the bug, asserting reports it." Adding a second, redundant filter in the search loop > would have done exactly what that line warns against. > > **The panic path itself is, and will remain, unexercised by the acceptance mechanism.** > `sk_hal_panic()` halts the machine — triggering it deliberately would mean the kernel > cannot reach `ok>`, which is incompatible with the three-arch boot being this project's > sole acceptance test. Nothing calls `stadium_evict()` yet regardless (same as items 3.4 > and 3.5), so this boot run does not exercise the check either way. Correctness rests on > the placement argument above, not a test. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-3.5 baseline — amd64 > (`logs/20260804-173311`), aarch64 (`logs/20260804-173350`), riscv64 > (`logs/20260804-173446`). > > **Correction, 2026-08-04, same day:** the line originally here claimed "Phase 3 core > complete" with items 3.1–3.6. That was premature — starting work on item 4.1 surfaced > that its own prerequisite (the per-VM free lists §22.3 describes) doesn't exist yet. > §25.4 gained a seventh item, 3.7, below. Phase 3 core is not complete until it is. - [x] **3.7 — Per-VM free lists.** Each VM holds its own free-list head index into the global array (§22.3); cells are drawn by popping that head, granted by Hera. Added 2026-08-04 after starting item 4.1 surfaced this as an unbuilt prerequisite — see item 3.6's correction note above. *Refs:* §22.3. > **DONE 2026-08-04.** `StadiumVMQuota` table (`stadium.c`, size `STADIUM_MAX_VM_COUNT`, > linearly searched by `vm_id`): `capsule_birth.c`'s `vm_id` is monotonic and never reused > (`next_vm_id` only increments, even across VM death — verified by reading, not assumed), > so it cannot index a table directly; a linear scan over 4 entries costs nothing. > > A new per-cell `stadium_owner` byte array (one byte per cell, same pattern as item 3.1's > discriminator bitmap) records which quota slot a cell belongs to — needed because > eviction must return a freed cell to the *correct* VM's list, and because eviction's > least-dense search must stay scoped to the evicting VM's own residents (quota > isolation: one VM's admission can never evict another VM's patron). A compile-time check > (`STADIUM_MAX_VM_COUNT <= 255`) confirms the quota-slot index fits the byte. > > Free-list linkage reuses each cell's own `link` field as a "next free cell" pointer while > unresident — `link` is documented only as generic "index into the Stadium, not a > pointer," so this is a repurposing of already-permitted, previously-unspecified storage, > not a header change. It does **not** answer the separate, still-open question of which > field would carry a multi-cell patron's first continuation-cell index — item 3.5's > `mass != 1` refusal stands exactly as it was. > > At `stadium_boot_init()`, every cell is chained into one list in ascending index order > and granted in full to `vm_id` 0 (Hera) — the only VM that exists (item 0.1). Ascending > order guarantees the first-ever pop returns cell 0, preserving item 3.6's "Hera is patron > zero" invariant once real birth-wiring calls `stadium_admit()`. > > `stadium_admit()`'s signature changed to `stadium_admit(vm_id, candidate)` — a change to > code shipped in item 3.5, amended there (see above). Pops the calling VM's free-list > head first (O(1)); only falls back to a same-VM-scoped eviction search if that list is > empty. > > **A real bug caught before the boot run, by a second review pass:** the zero-fill that > clears a header on eviction (and the initial free-list build) both leave `contains == 0` > — but 0 is Hera's valid index (item 3.1's earlier `STADIUM_CONTAINS_NONE` fix was about > exactly this collision), so every cell on a free list was silently readable as "contains > Hera." Fixed by explicitly setting `contains = STADIUM_CONTAINS_NONE` at both sites > (the boot-time chain-build loop, and `stadium_evict()`'s free-list-return step) rather > than leaving it to the zero-fill's incidental value. > > **Explicitly out of scope, reported not invented:** > - Granting quota to any VM other than Hera, and transferring capacity between VMs, is > capacity *arbitration* — item 1.3 left "how much capacity moves per eligible transfer" > explicitly open, so this item does not invent it. Only the boot-time all-to-Hera grant > exists; `stadium_owner` is set once at boot and never written again, so > `quota_slot_for_vm()` returns refusal for every `vm_id != 0`, permanently, until > something else writes to it. Item 4.2 ("Hermes native on the Stadium") will need both > the grant path and the owner-array writes — flagging now so it isn't a surprise there. > - Multi-cell continuation-chain attachment remains unresolved (see above); item 3.5's > refusal is untouched. > > **Unexercised at runtime,** same as items 3.4–3.6: nothing calls `stadium_admit()` or > `stadium_evict()` yet. The free-list pop path, the quota-scoped eviction fallback, and > the boot-time chain-build are all unexercised against real data. > > **Regression: clean.** All three architectures boot to `ok>` with identical > `dict_hash=0x3d4e1daf289da94f`, matching the item-3.6 baseline, and the `Stadium: N > cells (M KB)` boot line is unaffected in format — amd64 (`logs/20260804-180453`, `74234 > cells (4639 KB)`), aarch64 (`logs/20260804-180541`), riscv64 (`logs/20260804-180637`). > > **Correction, 2026-08-04, same day:** starting work on item 4.1 surfaced a further > prerequisite — see item 3.8 below. §25.4 gained an eighth item. - [x] **3.8 — VM identifiers as UUID/GUID.** Replaces `capsule_birth.c`'s monotonic `uint32_t vm_id` with a wider, RFC-4122-shaped 128-bit identifier. Added 2026-08-04 after starting item 4.1 surfaced the need to thread a `vm_id` into `stadium_admit()`'s new quota parameter, and Captain Bob ruled UUID/GUID rather than keeping the narrower type. *Refs:* §22.3 (item 3.7's quota table), capsule_birth.c's VM registry. > **DONE 2026-08-04.** New `VMUuid` type (`include/starkernel/vm_uuid.h`, > `src/starkernel/capsule/vm_uuid.c`): two `uint64_t` halves, formatted > RFC-4122-shaped (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) for logging. > > **Not real randomness — checked directly, not assumed.** This kernel has no RNG source > at all. Verified empirically against QEMU 10.2.1 rather than guessed: amd64's `RDRAND` > and riscv64's `Zkr` entropy extension are both real, available CPU features here (QEMU > accepts `-cpu qemu64,+rdrand` and `-cpu rv64,zkr=true` without error); aarch64 has > **no** RNG/RNDR property on any CPU model including `max` — checked exhaustively via > QMP's `query-cpu-model-expansion` against all 23 of `max`'s exposed properties, none > RNG-related. Captain Bob ruled a uniform fallback across all three ISAs rather than a > per-architecture split (real RNG on two, something else on the third). > > **The fallback is a deterministic PRNG (splitmix64 — public-domain, minimal), seeded > from the Mama capsule's content hash**, pre-filling a 16-entry FIFO pool at boot > (`vm_uuid_pool_init()`, called from `capsule_birth_mama()` right after the capsule hash > is known), refilled with another batch continuing the same stream when exhausted > (`vm_uuid_next()`) — exactly the shape Captain Bob asked for. Same capsule booted twice > produces the same id sequence, preserving the run-to-run reproducibility this session's > `dict_hash` regression check has relied on for every prior item. > > **Hera keeps a fixed, reserved id — all-zero — not drawn from the pool.** > `capsule_birth.c` uses `vm_id == 0` as a load-bearing sentinel in three places, found by > reading before writing any code: "Hera cannot be killed" (`capsule_vm_kill`), the same > check in `capsule_vm_kill_all_nonmama`, and the fleet heat-fanout parent-chain > terminator (`capsule_run.h`'s `parent_vm_id` comment: "self-referential, `parent_vm_id > == vm_id == 0`"). `vm_uuid_hera()` (all-zero) preserves all three with a cheap > equality check (`vm_uuid_is_hera()`). > > **Two real sentinel-collision bugs caught before they shipped, both the same class of > mistake `STADIUM_CONTAINS_NONE` was already fixed for once this session:** > - `vm_uuid_none()` (all-ones) is deliberately **not** all-zero, since all-zero is now > Hera's reserved value — used for "not yet assigned" placeholders > (`vm_registry_alloc()`'s embryo `vm_id` before birth completes) and "no VM" logging > (item 2.2's `FLEET_FULL` refusal, which happens before any VM is allocated). > - `StadiumVMQuota`'s "slot empty" state was already tracked by an `in_use` boolean > (item 3.7), not a `vm_id` sentinel value — so no second collision was actually > possible there; confirmed by re-reading item 3.7's own code rather than assumed, and > the dead, never-referenced `STADIUM_QUOTA_SLOT_EMPTY` macro item 3.7 defined "just in > case" was removed as part of this item's cleanup. > > **Blast radius, larger than first scoped — flagged mid-work rather than silently > absorbed:** beyond the originally-flagged `capsule_run.*`/`capsule_birth.*`/`stadium.*`, > compiling surfaced that `capsule_vm_physics.c`/`.h` (the fleet heat-transfer layer item > 2.1 modified earlier this session) has its own `vm_id`-keyed node table and walks > `parent_vm_id` chains via `capsule_vm_registry_get()` — the same identity space, so it > had to change too (`vm_physics_init`/`_retire`/`_touch`/`_heat_of`/`_find`/ > `_find_root_id`), plus its callers in `mama_forth_words.c` and > `sk_vm_bootstrap.c`. > > **One live FORTH word contract changed, by explicit ruling:** `CAPSULE-BIRTH` was > `( capsule-id -- vm-id )`, a single cell — can't hold 128 bits. Options were two cells, > a silent 64-bit truncation, or a separate small FORTH-only handle; Captain Bob picked > two cells ("there is doubles support in the FORTH std word set anyway"). New contract: > `( capsule-id -- vm-id-hi vm-id-lo )`, high cell on top, `vm_uuid_none()`'s hi/lo (both > all-ones) on any failure path including the early bounds-check return. `MAMA-VM-ID` > changed the same way: `( -- 0 0 )`, both cells zero since Hera's id is all-zero. > > **Regression: clean, across a genuinely large diff.** All three architectures boot to > `ok>` with identical `dict_hash=0x3d4e1daf289da94f`, matching the item-3.7 baseline — > amd64 (`logs/20260804-194631`), aarch64 (`logs/20260804-194711`), riscv64 > (`logs/20260804-194810`). A full (not standalone-file) kernel rebuild was used to catch > cross-file breakage before the boot run, given the size of this change; it surfaced the > `capsule_vm_physics.c` blast radius above that a narrower compile check would have > missed. > > **Phase 3 core complete.** Items 3.1–3.8 close out §25.4. --- ## 25.5 Phase 4 — Migrate the subsystems *One subsystem at a time, converted completely. Never two live heat mechanisms at once (§11).* - [x] **4.1 — Hot words onto the Stadium.** Replaces the round-robin eviction with density ranking, via the reservoir mechanism (§17.7) and a kernel-side `word_id → cell_index` map (no `DictEntry` change, decided 2026-08-05). *Refs:* §17.3, §17.7. > **Unblocked 2026-08-05 — §17.7 reads DECIDED.** Acceptance restated below now that the > mechanism itself changed; the original "measurable via `stats.evictions`/ > `stats.promotions`" presumed reusing the old cache's `HotwordsStats`, which this item > retires on the kernel side rather than extends. > > **Done, 2026-08-05.** Two rulings made mid-implementation (§17.7's addendum): Hera is now > actually birthed into cell 0 (`stadium_birth_hera()`, a deliberate scope addition, not > silently folded in) closing the cell-0 panic hazard the original design left open; the > quantum/cool-rate got two new Kconfig knobs (`STADIUM_WORD_HEAT_QUANTUM`, > `STADIUM_WORD_COOL_RATE_Q48`) with derived-not-fabricated defaults, flagged untuned. A > third addition, required for the item's own correctness rather than scope creep: a FORGET > coherence hook (`stadium_word_forget()`, called from `vm_dictionary_untrack_entry()`) > reclaims a resident word's cell before its `word_id` is recycled, closing an aliasing gap > of the same shape as the 2026-08-02 `block_words.c` bug. > > *Acceptance, verified:* > - Kernel builds only: all five `hotwords_cache_*` call sites in `dictionary_management.c` > (not just the two originally named) are bypassed under `__STARKERNEL__`. > - Word dispatch feeds the Stadium at all three of `vm_core.c`'s > `physics_execution_heat_increment()` call sites — an already-resident word gets the > reservoir-quantum touch; a non-resident word attempts Option B starter-grant admission. > - New Stadium-side promotion/eviction counters (`stadium_words_stats()`) and a > conservation check are printed to the boot console > (`stadium_words_print_boot_diagnostics()`) before the REPL starts. > - Hosted builds unaffected — confirmed via a clean hosted `make`. > - All three architectures booted to `ok>` with logs under `logs/20260805-130800/amd64/`, > `logs/20260805-130919/aarch64/`, `logs/20260805-131030/riscv64/`. `dict_hash` is > identical across all three (`0x3d4e1daf289da94f`) and identical in shape to pre-4.1 > parity output — untouched, as designed. Stadium diagnostics also identical across all > three: `promotions=354 evictions=0`, `resident_sum=65536 reservoir=0 sum=65536 > (Q48_ONE=65536)` — the conservation invariant closes exactly. - [x] **4.1a — Quota granting: Hermes's birth grant.** New prerequisite item, inserted 2026-08-05 while scoping 4.2 — found that no quota-granting mechanism exists at all. `quota_slot_for_vm()` refuses every `vm_id != 0` today, permanently, by design (item 3.5's note); `stadium_admit()`'s own doc and item 3.2's DONE note both defer per-VM free lists to "when Hera assigns a VM its quota," which nothing builds. Item 1.3 (§25.2) resolved *when* Hera arbitrates a capacity *transfer* between VMs that already hold quotas, but explicitly left "how much capacity moves" unresolved and out of scope (§22.5 #2) — that is the *recurring* mechanism, and stays open; this item is narrower: Hermes's one-time *initial* grant at birth, the same shape as Hera's own whole-pool grant at `stadium_boot_init()`, not an instance of the still-open recurring loop. *Refs:* §1.3, §22.3, §22.5 #2. > **Ruled, 2026-08-05:** > 1. **Reservoir is not part of this.** `stadium.c`'s own comment states the conservation > invariant per-VM — `Σ(resident heat) + reservoir == Q48_ONE` for *that VM's own > quota* — not a shared pool split across VMs. Hermes gets her own fresh `Q48_ONE` > reservoir at birth, the same pattern as Hera's boot grant, not a fraction of Hera's. > Only cell count is actually open. > 2. **Cell split: even.** At Hermes's birth, half of whatever cells are currently on > Hera's free list move to a new quota slot for Hermes. Hera's residents — including > pinned cell 0 — are never touched; only her free list is split. No tuned constant: an > even split needs no threshold, consistent with §22's "no tuned threshold" elsewhere in > this design. > > *Done, verified 2026-08-05.* `stadium_grant_quota(VMUuid new_vm_id, VMUuid from_vm_id)` > (`stadium.c`/`stadium.h`) exists as designed: counts `from_vm_id`'s free list, splits the > first half (by list-walk order) into a new quota slot for `new_vm_id` with `stadium_owner` > reassigned per moved cell, terminates both lists correctly, and grants a fresh `Q48_ONE` > reservoir. Refuses without crashing if `new_vm_id` already holds a quota, `from_vm_id` > holds none, the free list has fewer than 2 cells, or no empty quota slot remains. Wired > into every baby VM's birth (`capsule_birth.c`, right after `stadium_vm_id` is set, before > IDENTITY exec) — failure is non-fatal to birth itself, same as having no quota is today's > status quo for every VM. > > *Verified* via a boot-time self-test (`kernel_main.c`, right after item 4.1's diagnostic > print) using a synthetic identity — deliberately not `vm_uuid_next()`'s real birth pool > (would perturb the deterministic ID stream) and not a real capsule birth (item 0.1 pruned > automatic Hermes birth from `init.4th`; restoring that is item 4.2's job, not this one's). > All three architectures booted to `ok>` with logs under `logs/20260805-145714/amd64/`, > `logs/20260805-145806/aarch64/`, `logs/20260805-145902/riscv64/`, each printing identically: > `Stadium quota grant self-test: OK`, `Hera reservoir=0` (already fully committed to > resident words by item 4.1's own self-test, unchanged by this grant — correct, since this > item never touches reservoir on the donor side), `test-vm reservoir=65536` (a fresh > `Q48_ONE`, as ruled). `dict_hash` identical across all three and unchanged from item 4.1's > baseline (`0x3d4e1daf289da94f`), confirming this item added no dictionary word. - [x] **4.2 — Hermes native on the Stadium.** The proving ground; produces the effort number. *Refs:* §10. **Unblocked 2026-08-05** — item 4.1a closed; `stadium_grant_quota()` exists and is wired into every baby VM's birth. **Complete 2026-08-07** — all `Done when` bullets satisfied; see the effort number and MBR-scoping ruling below. > **Two rulings taken before work starts, 2026-08-05:** > 1. **`stadium_owner[idx]` fix folded into this item's scope**, by explicit Captain Bob > authorization (not a §25.0-rule-3 violation — this is the same "required for the > item's own correctness" precedent as 4.1's FORGET hook). `stadium_admit()` writes > `stadium_owner[idx]` on neither the free-list-pop nor the eviction-fallback path; > harmless while Hera is the only VM with a quota, but this item puts a second VM > (Hermes) on the Stadium, and without the fix a resident's evict-credit flows to the > wrong VM's reservoir. Fix ships as part of this item's commit, called out separately > in the acceptance below so it doesn't hide inside the migration diff. > 2. **C/FORTH boundary: new thin FORTH-callable primitives**, registered in C exactly > like `BIRTH`/`RUN`/`USE` (kernel-only, not shared/vendored), justified under > `.claude/CLAUDE.md`'s "raw hardware access, atomics, syscalls, freestanding kernel > ops" exception to "compose in FORTH first." Candidate surface — confirmed, not yet > implemented: > ``` > STADIUM-ADMIT ( identity heat behaviour -- cell | -1 ) > STADIUM-EVICT ( cell -- flag ) > STADIUM-RES@ ( vm-id -- heat ) > STADIUM-RES-PULL ( vm-id qty -- heat ) > STADIUM-RES-PUSH ( vm-id heat -- ) > ``` > Exact stack signatures and error handling to be finalized during implementation, not > invented here. `HERMES.md`'s non-negotiable — all Hermes-side logic in StarForth, > zero new C beyond this primitive layer — still applies; these five words are the > entire C surface this item may add. > 3. **`vm_core.c`'s hardcoded `vm_uuid_hera()` — Option A, add a `VMUuid` field to `VM`.** > Found while scoping this item, not a new bug: all three `stadium_word_dispatch()` > call sites in `vm_core.c` (item 4.1) hardcode `vm_uuid_hera()`, with an inline > comment already naming this as 4.2's job ("Tripod is pruned to Hera alone; revisit > at item 4.2"). Fixing it requires a running `VM*` to know its own identity, which > nothing today provides — `VMUuid` exists only on `VMRegistryEntry` (`capsule_run.h`), > never on `VM` (`include/vm.h`) itself. Ruled: add a `VMUuid` field to `VM`, guarded > `#ifdef __STARKERNEL__` in the same block as the existing `VMCallState` lifecycle > fields (`include/vm.h` ~line 523) — not Option B (threading vm_id through the call > chain without touching the struct). `VM` is shared/vendored, same as `DictEntry`, so > the hosted build's layout must stay untouched outside the `__STARKERNEL__` guard. > Set once, at VM creation, from the same `VMRegistryEntry.vm_id` the birth path > already assigns (`capsule_birth.c`) — not invented at the dispatch call sites. > 4. **Primitive surface grows from five to seven.** Found while reading Hermes's actual > implementation (`capsules/hermes/init.4th`): `MSG-COOL-ALL`/`CH-COOL-ALL` (blocks > 4108/4114) mutate each live node's own heat field in place every `HERMES-TICK`, and > `MSG-TOTAL-HEAT`/`CH-TOTAL-HEAT` sum it. None of the original five primitives expose > a resident cell's own `heat` field at all — only admission, eviction, and the calling > VM's reservoir. Ruled: two more primitives, same implicit-self discipline as the > original five (cell must belong to the calling VM's own quota): > ``` > STADIUM-HEAT@ ( cell -- heat ) > STADIUM-HEAT! ( new-heat cell -- ) > ``` > `STADIUM-HEAT!` reconciles the reservoir delta atomically in C — pulls from the > calling VM's reservoir if `new-heat` is higher than current (refusing, leaving heat > unchanged, if the reservoir can't cover it), pushes back if lower — the same shape as > `stadium_word_dispatch()`'s own cooling code. Conservation is never left to FORTH to > get right by remembering to call `STADIUM-RES-PULL`/`-PUSH` itself; a single call is > both the write and the correct accounting. Serves two callers: a cooling tick > (`heat × Q-DECAY`, replacing `MSG-COOL-ONE`'s in-place multiply) and a floor-refresh > (`COMMON-INIT`/`HERMES-TICK` reset `COMMON-CH`'s heat to a fixed `Q.1/3` unconditionally, > not a decay — needs the same delta-reconciling write, just with a different target > value). `STADIUM-HEAT@` alone serves `MSG-TOTAL-HEAT`/`CH-TOTAL-HEAT`'s summation and > `MSG-REAP`/`CH-REAP-SAFE`'s heat-reached-zero check. Cooling cadence stays entirely in > Hermes's own FORTH `HERMES-TICK` loop (rewritten to call these, not a new C-side > per-tick sweep) — matches `HERMES.md`'s language constraint and the fact that > `HERMES-TICK` already owns this loop; nothing about moving the heat storage changes > who decides when to cool. > > **Open, surfaced not resolved:** mapping Hermes's message/channel lifecycle onto the > closed `STADIUM_BEHAVIOUR_*` set (`MIGRATE`/`DELIVER`/`EXPIRE`/`COOL`) — `DELIVER` and > `EXPIRE` currently have dispatch cases in `stadium.c` but no consumer, and were > apparently reserved for exactly this. Which tag maps to a message and which (if either) > to a channel is implementation work for this item, not decided here. Also open: whether > migrating message/channel heat into the Stadium's conserved 1.0 dissolves or changes > `HERMES.md`'s G8 note (`HERMES-K`/`K-FLEET` integration deferred pending cross-VM return > values) — the reservoir mechanism (`stadium_reservoir_pull`/`push`) already crosses VM > boundaries, so this may no longer be blocked the way G8 describes. Raise during > implementation; do not resolve by assumption. > > **Bug found and fixed during implementation, 2026-08-06 — amd64-only dictionary > corruption, root cause was a missing `-fno-pic`, not Stadium logic.** While exercising > Hermes's migrated words in this item's self-test (`kernel_main.c`), `MSG-COOL-ALL` > became unreachable via `vm_find_word()` immediately after `MSG-DELIVER-ALL` ran — > amd64 only; aarch64 and riscv64 never showed it. Initial hypotheses (capsule-loader > forward-reference retry interaction, GDB-perturbed timing, arena exhaustion, a stray > `dict_reorganize_buckets_by_heat()` race) were each tested and ruled out by direct > print-based bisection (GDB is unusable on this kernel — see below). Root cause, > confirmed by disassembling the actual booted `starkernel_loader.efi`: > `dict_find_word_heat_aware()` (`dictionary_heat_optimization.c`) and `vm_find_word()` > (`dictionary_management.c`) both reference the same extern globals > (`sf_fc_list`/`sf_fc_count`/`sf_fc_cap`, the dictionary's first-character lookup index), > but GCC compiled the two files' references differently: `vm_find_word()`, in the same > translation unit as the arrays' definition, got direct `lea sym(%rip), %reg` addressing; > `dict_find_word_heat_aware()`, a genuine cross-TU extern reference, got GOT-indirect > `mov sym@GOTPCREL(%rip), %reg` addressing (`R_X86_64_REX_GOTPCRELX`). The latter requires > a populated Global Offset Table slot — normally a dynamic linker's job. This kernel is a > freestanding, statically-linked UEFI PE image with no dynamic linker and no `.got` > section; the "GOT slot" GCC emitted the reference against is just an ordinary > zero-initialized `.bss` cell that nothing ever writes. The load silently returns NULL > instead of the array's real address, `vm_find_word()`'s `!bucket || n==0` guard reads it > as "empty," and the word reports `UNKNOWN WORD` even though its `DictEntry` is fully > intact (verified by a manual `vm->latest`→`link` chain walk). Whether a given reference > gets the safe or unsafe addressing mode is a per-call-site GCC codegen heuristic > sensitive to surrounding code size — which is why the symptom appeared and disappeared > across unrelated one-line changes (even hitting an unrelated symbol, `BIRTH`'s own > dictionary entry, once), and why it looked for a long time like a timing-sensitive > memory-corruption bug rather than a static codegen/build-flag one. > > **Fix:** `Makefile.starkernel`'s amd64 `ARCH_CFLAGS` now appends `-fno-pic -fno-pie`, > overriding `COMMON_CFLAGS`'s `-fPIC` for amd64 only (GCC takes the last flag on the > command line; `ARCH_CFLAGS` is appended after `-fPIC` in `COMMON_CFLAGS`'s definition). > amd64 is a fixed-base, statically-linked image with no dynamic-linker use for PIC in the > first place, so this is a correctness fix, not a workaround. aarch64/riscv64 keep > `-fPIC` — riscv64's loader link step (`ld -shared -Bsymbolic`) genuinely requires it and > fails to link without it; aarch64 was never observed to hit this bug (different > toolchain, `clang`+`lld-link`, different codegen heuristics). Also removed > `-DPLATFORM_TIME_NO_INLINE` from `COMMON_CFLAGS` (and its now-redundant explanatory > comment) — a prior one-off workaround for the identical bug class, applied specifically > to `sf_monotonic_ns()`'s access to `sf_time_backend`, made unnecessary once amd64 got > the real fix. Confirmed no regression on any architecture: all three still boot to > `ok>` and pass the full self-test with the flag removed. `shim.c`/ > `physics_hotwords_cache.c`'s own local `#define PLATFORM_TIME_NO_INLINE` (their concrete, > non-inline implementations of `sf_monotonic_ns()` etc.) were left as-is — out of scope > for this fix, and harmless either way. > > **Also fixed as a side effect, kept though not the active bug:** `elf_apply_relocations()` > (`src/starkernel/boot/elf_loader.c`) didn't handle `R_X86_64_PC32`/`R_X86_64_PLT32` > either, discovered while chasing an earlier (wrong) theory that this was a runtime ELF > relocation bug. That code path turned out to be dead for this build — `uefi_loader.c` > calls `kernel_main()` as a direct function call under `MONOLITHIC_BUILD` (the default > here), never invoking `elf_load_kernel()`/`elf_apply_relocations()` at all; the actual > boot image is a standard PE32+ UEFI application, relocated by OVMF's own PE loader, not > by this custom ELF loader. The relocation-type gap is real for the non-monolithic > split-build path though (`elf_load_kernel()` returns 0 — hard failure — on any > unhandled type, and the loop aborts the rest of that RELA section on the first one hit), > so the handling was kept as a legitimate robustness fix rather than reverted. > > **Process note:** GDB+QEMU is confirmed unusable for debugging this kernel — the custom > UEFI loader relocates/loads the image such that static-symbol software breakpoints never > fire, and a hardware breakpoint (`hbreak`) not only never fired but its mere presence > caused a different, more severe corruption (`BIRTH` itself became `UNKNOWN WORD`) before > any breakpoint triggered — likely a parity/dict-hash boot-gate reacting to the debugger > session, not "timing perturbation" as first guessed. Print-based bisection > (`console_puts`/`print_uint`, plus `log_message(LOG_ERROR, ...)` — `LOG_INFO` is below > the active log threshold and never appears in the serial log, a separate dead end closed > along the way) is the only viable method for this kernel today. > > **Blocker found 2026-08-06, ruled and fixed 2026-08-07 — item 4.1 and item 4.2 silently > share one finite per-VM reservoir, and word-execution admission alone can exhaust it > before any application-level allocation runs. This failed the K≡1.0 `Done when` bullet > below and was not a code bug to just patch — it was a design question spanning both > items, reported for a ruling rather than resolved unilaterally (§25.0 rule 3). Captain > Bob ruled option 4 below (reserve a floor); implementation and result are at the end of > this note.** > > After the `Q.SLOT` admission-heat fix (below) closed the original `MSG-SEND`/`CH-ACCEPT` > over-admission bug, `HERMES-K` still read `0` instead of `65536`. Three prints in one > boot discriminated the cause: `stadium_reservoir_peek(Hermes)` reads `65536` immediately > after `BIRTH` (the one-time grant, item 4.1a, is fine) but is already `0` — and > `COMMON-CH`'s own heat is already `0` — immediately after `CD-INIT` finishes, before > `HERMES-MSG-TEST`/`MSG-DELIVER-ALL`/anything else in the self-test runs. So this is not a > Stadium cell getting silently reassigned out from under `COMMON-CH` after the fact > (aliasing); `COMMON-INIT`'s own `CH-ALLOC` call, partway through `CD-INIT`, never got > funded in the first place. > > Root cause: `stadium_word_dispatch()` (item 4.1, `stadium_words.c:111`) pulls > `STADIUM_WORD_HEAT_QUANTUM` (2048) from the dispatching VM's reservoir on **every single > word dispatch**, not just the first time a word is admitted — the "already resident" > branch (line 142) does `h->heat += stadium_reservoir_pull(vm_id, STADIUM_WORD_HEAT_QUANTUM)` > unconditionally, every call. `CD-INIT`'s own `MSG-INIT-FREE`/`CH-INIT-FREE`/ > `MBR-INIT-FREE` loops alone dispatch several hundred words (32 + 16 + 64 iterations, each > several words deep) before `COMMON-INIT` ever runs. At 2048 per dispatch, a VM's entire > 65536 reservoir is exhausted by roughly 32 total word dispatches — trivially reached > within `CD-INIT`'s first loop, let alone the rest of Hermes's boot. The boot log's own > `promotions=145` figure (Hermes's dict-check diagnostics) makes this arithmetic visible > directly: 145 × 2048 = 296,960, about 4.5× her entire conserved share, from > word-execution tracking alone. This applies to any VM doing non-trivial work, not > something specific to Hermes or to messages/channels — Hera's own reservoir has read `0` > in every log this entire session, for the same reason, just never surfaced as a problem > because nothing previously tried to spend Hera's reservoir on anything else. > > Options, no ranking, not decided here: > 1. **Separate reservoirs per VM** — one for word-execution tracking (item 4.1), one for > application-level use (item 4.2 and whatever comes after it). Most invasive: splits > `stadium_quotas[slot].reservoir` or the one-time grant itself, touches item 4.1's > already-shipped design and its recorded DoE baseline. > 2. **Exempt certain VMs from word-execution admission entirely** — e.g., only Hera (or > only VMs with no item-4.2-style application economy) get word-heat tracking. Requires > a new per-VM-class distinction that doesn't exist today. > 3. **Re-scope `STADIUM_WORD_HEAT_QUANTUM`** — smaller, or charged per-unique-word instead > of per-dispatch. Touches a Kconfig default that already feeds item 4.1's recorded DoE > measurements; re-tuning it here could invalidate that baseline. > 4. **Reserve a floor within the shared reservoir** that word-execution admission cannot > dip below, mirroring `COMMON-CH`'s own `Q.1/3` floor pattern but at the reservoir > level instead of a single resident. New mechanism, not yet designed. > > **Ruling, 2026-08-07: option 4.** Implemented as `word_dispatch_pull()` > (`stadium_words.c`), a static helper wrapping `stadium_reservoir_pull()` for > `stadium_word_dispatch()`'s two call sites only (both the already-resident re-heat pull > and the not-yet-resident starter-grant pull) — clamped so a pull never takes the > reservoir below `Q48_ONE / 3`, the same "VM-COUNT=3 fair share" figure `COMMON-CH`'s own > floor already uses, not a new invented number. Application-level pulls > (`stadium_reservoir_pull()` called directly, e.g. via `STADIUM-RES-PULL`) are untouched — > only word-execution admission respects the ceiling on its own consumption. Verified: the > eviction-credit demo now shows a real transfer (`resident_sum` −1612, `reservoir` +1612, > exactly, when `COMMON-CH` is evicted) instead of the prior `0`→`0` no-op, and the > Stadium's own conservation line closes exactly on every boot, every architecture: > `resident_sum=43691 reservoir=21845 sum=65536`. > > This alone brought `HERMES-K` from `0` to `43002` — real, but not exact, because > `HERMES-K`'s formula (`MSG-TOTAL-HEAT CH-TOTAL-HEAT + STADIUM-RES@ +`) has no term for > word-execution residents' heat, which the floor now deliberately leaves nonzero. Second > ruling, same date: **add that term.** New accessor `stadium_words_resident_heat(vm_id)` > (`stadium_words.c`) sums heat over only a VM's own word-execution residents (walking its > `word_slots` map, not `stadium_resident_sum()`'s full ownership scan, which would double- > count messages/channels already in `MSG-TOTAL-HEAT`/`CH-TOTAL-HEAT`), exposed as an > eighth `STADIUM-*` primitive, `STADIUM-WORD-HEAT ( -- heat )`, same implicit-self > discipline as the other seven. `HERMES-K` becomes > `MSG-TOTAL-HEAT CH-TOTAL-HEAT + STADIUM-RES@ + STADIUM-WORD-HEAT + ;`. Confirmed on all > three architectures: `HERMES-K` prints exactly `65536`, K≡1.0, closing the item's > headline invariant. > > *Done when:* > - The eight `STADIUM-*` FORTH primitives exist, are kernel-only (not in the shared/ > vendored word set), and are exercised by at least one Hermes word each. > - `stadium_owner[idx]` is written correctly on both the free-list-pop and > eviction-fallback paths in `stadium_admit()`, verified by a resident cell's > evict-credit landing in the correct VM's reservoir with two VMs holding quotas > (Hera + Hermes) — not just asserted from reading the code. > - `VM.stadium_vm_id` (or equivalent name chosen at implementation time) exists under > `__STARKERNEL__`, is set correctly at Hermes's birth, and all three `vm_core.c` > `stadium_word_dispatch()` call sites pass it instead of the hardcoded > `vm_uuid_hera()` — verified by a Hermes-dispatched word's heat landing in Hermes's > own reservoir, not Hera's, with both VMs' conservation checks closing independently. > - Hermes's message and channel lifecycle (`MSG-ALLOC`/`MSG-FREE-NODE`, `CH-ALLOC`/ > `CH-FREE-NODE`) run entirely through Stadium admission/eviction — no parallel free > list, no parallel heat field. Per §11, this is atomic: `MSG-HEAT@/!`, `MSG-COOL-ONE`, > `MSG-COOL-ALL`, `CH-HEAT@/!`, `CH-COOL-ALL`, `CH-TOTAL-HEAT`, `MSG-TOTAL-HEAT` either > come out in this same change or are rewritten to read/write the Stadium cell instead > of a local field — never both mechanisms live at once. **`MBR-ALLOC`/`MBR-FREE-NODE` > ruled out of scope, 2026-08-07 — see below.** > > **Ruling, 2026-08-07: `MBR-ALLOC`/`MBR-FREE-NODE` stay on their own free list, not > migrated onto the Stadium.** This bullet originally named them alongside `MSG-*`/`CH-*`. > Checked the actual record layout (`capsules/hermes/init.4th`): an MBR record has exactly > two fields, `MBR-NEXT@` (link) and `MBR-VM@` (owning VM id) — a pure channel-membership > relationship, no heat field, never had one. The bullet's own stated purpose is "no > parallel free list, no parallel heat field" — for MBR, "no parallel heat field" is > already true vacuously, since none exists to be parallel to. Forcing MBR records through > `stadium_admit()`/`stadium_evict()` would mean inventing a heat/mass/behaviour for > something structurally without either, spending Stadium cells and reservoir budget on > records the item's actual design goal (a conserved, evictable-under-pressure heat > economy) has no reason to govern — "does VM X belong to channel Y" is not a quantity > that cools, competes for capacity, or needs eviction pressure. Their original inclusion > in this bullet reads as a completeness gesture written before the field layout was > checked, not a deliberate requirement. `MBR-ALLOC`/`MBR-FREE-NODE`'s own free list > (`capsules/hermes/init.4th`, unchanged this item) is correct as-is. > - Blocks 4110–4113 (Artemis) are untouched, per `HERMES.md`'s block-map lock. Any new or > changed Hermes block is verified with `mkcapsule --lint` before commit, per > `experiments/bare_metal/README.md`. > - The POST suite (regression gate per §10) passes. > - **The effort number is recorded explicitly** — per §10, "what Hermes costs is the > multiplier for everything else." Report at minimum: wall-clock/session time spent, > lines changed (FORTH + the eight-primitive C surface, split out), and file count > touched, so 4.3/4.4 can be estimated from a real data point rather than guessed. > - All three architectures boot to `ok>`/`zuse)ok>` with logs under `logs/`, and > Hermes's own conservation check (K≡1.0 across messages + channels + reservoir) closes > exactly, reported the same way item 4.1 reported `resident_sum`/`reservoir`/`sum`. > > **Effort number, reported 2026-08-07:** > - **Session time.** This conversation's own boot-log timestamps span roughly 10 hours > elapsed (`logs/20260806-153504` through `logs/20260807-013712`), covering: the amd64 > GOT-indirect-addressing corruption investigation and fix (unrelated to Stadium logic, > committed separately as `0a7f144`), the item-4.2 acceptance-status survey against this > punch-list entry, the `Q.SLOT` admission-heat fix, the word-execution reservoir-floor > fix, and the `STADIUM-WORD-HEAT` addition that closed K≡1.0. This does **not** include > whatever time the original seven-primitive implementation and capsule migration > (already in place when this session's survey began) cost in an earlier session — no > visibility into that, not estimated rather than guessed. > - **Lines changed, split FORTH vs. C surface** (`git diff --stat`, this session's > contribution only — the pre-existing implementation's own diff is included since it > was still uncommitted when measured, but its authorship/timing is the caveat above): > - FORTH (`capsules/hermes/init.4th`): +116 / −45 (161 changed), 1 file. > - C, the eight-primitive `STADIUM-*` surface + Stadium core (`mama_forth_words.c`, > `stadium.c`, `stadium_words.c`, `stadium.h`, `stadium_words.h`, `vm.h`): +511 / −69 > (580 changed), 6 files. > - C, other wiring (`capsule_birth.c`, `sk_vm_bootstrap.c`, `vm_core.c`, > `dictionary_management.c`): +13 / −6 (19 changed), 4 files. > - Self-test scaffolding (`kernel_main.c`, diagnostic-only, not production code): > +119 / −0, 1 file. > - **Total: 12 implementation files, +759 / −120 (879 lines changed).** > - **File count:** 12 implementation files (13 including this write-up in `FABRIC.md` > itself). - [ ] **4.3 — Console.** Settles 1.11 as part of the work. *Refs:* §17.5. > **Note, 2026-08-05: Captain Bob wants a discussion before any work starts on this item.** > Do not begin 4.3 on an unblock-and-go basis the way 4.1 was — raise it and wait. > > **Discussion held 2026-08-07.** `.claude/CONSOLE.md` is a rough prior working draft, > superseded — not edited further, not treated as authoritative. Console's design lives > in this document from here on. First slice broken out below as 4.3.1–4.3.4. Explicitly > out of scope for all four: raster image (PNG/JPEG) rendering as fabric backgrounds — > real direction, raised 2026-08-07, deliberately deferred past this slice; also fonts, > scrolling, cursor/VT100 semantics, the Hermes message protocol, and Console as a fleet > VM under Hera's birth protocol — later 4.3.x items, scoped once this slice is reviewed. - [x] **4.3.1 — Framebuffer sanity: draw a test pattern.** Confirm `framebuffer.c` is wired to the real UEFI GOP `BootInfo` and draw a simple orientation-revealing test pattern, using the existing raw pixel primitives only — no coordinate/Z machinery yet. *Refs:* §27.1. > **Done, 2026-08-07.** `fb_draw_orientation_test()` added to `framebuffer.c`/`.h` — fills > the four raster corners RED/GREEN/BLUE/YELLOW via `fb_fill_rect` only. Wired into > `kernel_main.c` calling `fb_init()` directly; `console_fb_init()`/`vt100_init()` removed > from the boot path per Captain Bob's direction (vt100.c/console.c are obsolete, superseded > by the fabric redesign, not to be exercised even incidentally). > > **Bug found and fixed, not scope creep — the diagnostic did its job.** First screendump > (via 4.3.2) showed a clean R↔B channel swap (G correct, R and B corners exchanged) — > spatial placement was correct, so this ruled out flip/rotation but caught a real color > bug: `framebuffer.c`'s `pack_pixel()` had its `FB_PIXEL_RGBX32`/`FB_PIXEL_BGRX32` branches > swapped relative to UEFI GOP's own byte-order naming convention (pre-existing bug, not > introduced this item). Fixed by swapping the two `pack_pixel` return bodies to match > `framebuffer.h`'s already-correct doc comments; `kernel_main.c`'s GOP-format `switch` > needed no change. Re-verified via a second screendump: all four corners render correctly > (`fb/qemu-screenshot-20260807-113612.png`). > > Not addressed, not in scope: the pre-existing UEFI loader boot-log text remains visible > behind the corner blocks, since this diagnostic paints four small rectangles and does not > clear the framebuffer — expected, not a bug. - [x] **4.3.2 — QEMU screenshot capability.** Add a monitor/QMP socket to the `qemu` targets (mirroring the existing serial-socket pattern) so `screendump` can be issued and the 4.3.1 test pattern actually inspected. None exists today — all three targets currently run with `-display none` and no monitor attached. *Refs:* §27.2. > **Done, 2026-08-07 — mechanism already existed, didn't need building.** > `scripts/qemu_screenshot.sh` was already a complete, working amd64 screendump path > (monitor UNIX socket + `socat` + HMP `screendump`), just not wired into any > `Makefile.starkernel` target and not previously exercised this session — 34 prior > screenshots already sat in `logs/` from earlier use. Changed: output PNG now goes to a > new top-level `fb/` directory (tracked in git, per Captain Bob — not `logs/`, not a > gitignored temp dir); added a `python3`+PIL fallback for PPM→PNG conversion since > `imagemagick` isn't installed on this machine. Left as a standalone script, not wired into > a Makefile target, per direction — run directly for now. aarch64/riscv64 not covered by > this script; not needed for 4.3.1's amd64-only diagnostic. > > **Reversed same day.** `fb/` is now gitignored after all (Captain Bob: these are > throwaway local verification images, never meant to be committed). Screenshot paths > cited in this document's other acceptance notes (4.3.1, 4.3.3, 4.3.3b, 4.3.4) still > describe what was actually seen at the time, but those files are local-only now, not > retrievable from git history in their originally-committed form — the commits that added > them are still in history, just superseded by the untrack commit that follows. - [x] **4.3.3 — Cartesian coordinate machinery.** Origin bottom-left `(0, 0)`, Y-up, plus a new Z axis (depth-into-screen, not height) and a fixed orthographic projection as a placeholder — not the final projection, no perspective/camera work yet. **Angle settled 2026-08-07: true 45° cavalier.** New C primitives `PLOT ( x y color -- )`, `FB-WIDTH`, `FB-HEIGHT` (raw hardware boundary, no Cartesian awareness); new FORTH capsule `capsules/fabric.4th` (blocks 4900+) for `PROJECT`/`CART-Y`/`CART-PLOT`, per the compose-in-FORTH-first rule — the transform is policy, not hardware access. *Refs:* §27.3. > **Done, 2026-08-07.** `register_framebuffer_words()` (Module 28) adds `PLOT`/`FB-WIDTH`/ > `FB-HEIGHT` — kernel-only, no-op on hosted builds, same pattern as every other module. > `capsules/fabric.4th` (blocks 4900–4902, lint-clean per `mkcapsule --lint`) defines > `COS45`/`Z->DELTA`/`PROJECT`/`CART-Y`/`CART-PLOT`. > > **A second real bug found and fixed, not scope creep.** Live-tested `CART-PLOT` over the > serial socket (same injection technique the DoE machinery uses) and hit a silent `ERROR` > on the capsule's own `VARIABLE ZD`, while an identical `VARIABLE` typed live at the REPL > worked fine. Traced to `defining_word_variable()` (`defining_words.c:471`): it captures > `vm->here` as the variable's address with no alignment call first, and `vm_load_cell`/ > `vm_store_cell` require 8-byte-aligned addresses. `ZD` landed at `945` (misaligned) purely > because of what preceded it in the capsule; `TESTV`/`ZD2` defined live happened to land on > aligned addresses by luck. This is a real deviation from FORTH-83/ANS, which specifies > `VARIABLE` reserves an *aligned* cell. Fixed with one line (`vm_align(vm)` before capturing > `addr`) — `ALIGN` already existed as a word (`dictionary_words.c`) but `VARIABLE` wasn't > calling it. Fixes every `VARIABLE` in the system, not just this capsule's — other capsules > (`doe.4th`, `init-4.4th`) were landing aligned by luck, not by guarantee. Both hosted and > kernel builds recompiled clean after the fix. > > **Verified end-to-end**, amd64, via the same manual-injection + `screendump` technique: > plotted 4 marker points (origin, +100 X, +100 Y, +50 Z; a 3×3 cluster each for visibility) > and confirmed all four landed at hand-calculated raster coordinates — including the > diagonal up-right shift for the Z-axis point, confirming the 45° cavalier projection math > is correct, not just non-crashing. Screenshot: `fb/fabric-test-cart-plot.png`. 4.3.1's > corner diagnostic still renders correctly in the same shot — no regression. > > Not wired into `init.4th`'s boot chain, per plan — Console isn't a fleet VM yet. - [x] **4.3.3a — Q48.16 trigonometry.** `Q.SIN`/`Q.COS` (radian input) added to `q48_16.c`, Taylor series after range-reducing into `[-π, π]` — same pattern as this file's existing `Q.LOG`/`Q.EXP`/`Q.SQRT`, not a new precedent. Raised 2026-08-07 while scoping 4.3.3: needed by 4.3.3b, does not exist anywhere in this codebase today (checked). *Refs:* §27.3. > **Done, 2026-08-07.** `q48_reduce_angle()` (single integer division on the raw Q48.16 > representations to strip full `2·PI_Q48` turns, then a bounded fix-up loop) plus > `q48_sin_approx`/`q48_cos_approx` (Taylor series, terms `n=3,5,7,9,11` / `n=2,4,6,8,10`, > early exit below 10). `PI_Q48 = 205887`; `TWO_PI_Q48` is *derived* as `2·PI_Q48` rather > than independently rounded, so the ±π reduction boundary has no seam. > > **A real duplication, not previously flagged:** this codebase has *two* independent > Q48.16 implementations — `src/word_source/q48_16_words.c` (vendored/hosted) and > `src/starkernel/math/q48_16.c` (kernel-only; the kernel build does not compile the > former at all). Found the hard way — the hosted build linked fine, the kernel build > failed with `undefined reference to q48_sin_approx` until the same two functions were > added to both files (plus both `q48_16.h` headers — `include/q48_16.h` and > `include/starkernel/q48_16.h`, which also declare the same functions independently). > Not fixed at the root (de-duplicating the two implementations is a much larger change > than this item), just navigated correctly — `Q.LOG`/`Q.EXP`/`Q.SQRT` already had this > same four-file duplication, unremarked until now. > > **Verified live on amd64** via serial injection: `sin(0)=0`, `cos(0)=65536` (exact), > `sin(π/2)=65536`, `cos(π/2)=0` (exact), `sin(-π/2)=-65536` (exact, confirms the odd- > function sign handling), `sin(π)≈-27` (residual from `PI_Q48` rounding, ~0.04%), > `cos(π)≈-65656` (Taylor truncation near the interval edge, ~0.18%), and `sin(3π)` reduces > to the same `-27` as `sin(π)`, confirming range reduction across multiple turns. Both > hosted and kernel (amd64) builds clean. - [x] **4.3.3b — Geometry drawing primitive wordset.** `LINE`, `CIRCLE`, `ARC`, `ELLIPSE` in `capsules/fabric.4th`, built on 4.3.3's `PLOT`/`CART-PLOT` and 4.3.3a's `Q.SIN`/`Q.COS`. Raised 2026-08-07. Q48.16 throughout; resolution-agnostic (48 integer bits comfortably covers 1080p and well beyond — no hardcoded viewport assumptions). *Refs:* §27.3. > **Done, 2026-08-07.** Blocks 4903–4912. `TO-RASTER` factored out of `CART-PLOT` (same > behavior, not a change) so `LINE` can project both endpoints once and Bresenham the > straight line between them in raster space — valid because the cavalier projection is > linear, so projecting endpoints and interpolating is equivalent to projecting every point > along the line. `LINE` itself split across three helper words (`LINE-SETUP`, > `LINE-DONE?`/`LINE-STUCK?`, `LINE-STEP`) — discovered mid-implementation that colon > definitions **cannot span block boundaries** in this capsule loader (verified with a > throwaway test capsule: the continuation lands in a `[CAPSULE][DEFER]` path that never > resolves and errors out), so anything too long for one 16-line/64-char block has to be > factored into separate, block-local word definitions instead. `CIRCLE`/`ELLIPSE` are > 36-segment polygon approximations (`LINE` calls between consecutive `Q.SIN`/`Q.COS` > points); `ARC` is the same at 18 segments over a caller-supplied `[a0, a1]` radian range. > > **A fourth real bug, this one serious — found, fixed, verified with the recommended fix > applied both times.** `CIRCLE`'s first live test rendered only its first quadrant, then > a follow-up test call hung the VM for several minutes before being killed. Root cause: > `q48_to_u64()` (`include/q48_16.h` and `include/starkernel/q48_16.h`, backing > `Q.TO-INT`) did `q >> 16` as an **unsigned logical shift**. For any negative `q48_16_t` — > inevitable once `Q.SIN`/`Q.COS` leave the first quadrant — this produces a huge garbage > integer instead of sign-extending. That garbage became a bogus `LINE` target, and > `LINE-STEP`'s Bresenham loop had no bound, so it churned for a very long time trying to > converge on a point that was effectively unreachable. Fixed by shifting through a signed > `int64_t` intermediate (bit-identical output for the non-negative case, which is all the > inference engine's own caller ever produces). Independently, added `LINE-STUCK?` > (`LSTEPS` counter vs. `FB-WIDTH + FB-HEIGHT`, the true worst case for any on-screen line) > as a defense-in-depth cap, so a future bad target degrades to "stops drawing" rather than > hanging the VM again. > > **Verified live on amd64**, fresh boot after both fixes: `-65536 Q.TO-INT .` now prints > `-1`. `LINE`, `CIRCLE`, `ARC` (semicircle, 0 to π), and `ELLIPSE` all completed without > hanging or erroring, and a combined screendump shows all four rendering correctly and > distinctly — full circle, correct upper-half arc, properly proportioned ellipse (wider > than tall, matching unequal radii), and the earlier diagonal `LINE` test. > `fb/amd64/geom-test-circle-fixed.png`, `fb/amd64/geom-test-full-wordset.png`. - [x] **4.3.4 — Checkpoint: draw a cube.** First real exercise of the 4.3.3/4.3.3a/4.3.3b coordinate/projection/geometry machinery — cube edges use `LINE`. Stop and review here before scoping the next 4.3.x item — not expected to be fast. *Refs:* §27.4. > **Done, 2026-08-07.** Block 4913–4915. Vertices are bit-coded: `VERT ( n -- x y z )` > reads bits 0/1/2 of `n` as the sign of the X/Y/Z offset from center (`±CS`), so all 8 > corners come from one word instead of 8 hand-written coordinate triples. `EDGE > ( n1 n2 color -- )` resolves both corners via `VERT` and calls `LINE`. `CUBE > ( cx cy cz s color -- )` is 12 `EDGE` calls — 4 bottom, 4 top, 4 vertical — grouped by > face for readability, not because the grouping means anything to the machinery. > > No new bugs this item — first time in the 4.3.3.x sequence that's been true, which is > itself a small signal that `Q.TO-INT`/the `VARIABLE` alignment fix/the `LINE-STUCK?` > cap were the real gaps, not something still lurking in `LINE`/`PROJECT`/`CART-Y`. > > **Verified live on amd64**: `640 400 0 100 16777215 CUBE` (white, half-size 100, > centered mid-screen) completed cleanly, no errors, no `LINE-STUCK?` trips. Screendump > (`fb/amd64/cube-4.3.4.png`) shows a correct wireframe cube — front face square, back > face square offset diagonally up-right by exactly the 45° cavalier projection's > depth term, all 12 edges connecting at the right corners, no crossed or broken lines. > > **Correction, same day: the first pass above was amd64-only, and that wasn't flagged.** > Asked for cube screenshots on all three ISAs surfaced that aarch64 and riscv64 had no > framebuffer device at all — both booted with `GOP: protocol not found`. The "all three > architectures boot clean" acceptance checks run all session were REPL/`dict_hash` > parity, a different axis entirely from GOP/framebuffer presence; conflating the two was > an error. Fixed by adding `-device ramfb` (EDK2's firmware-only GOP framebuffer, no > guest driver needed) to both architectures' `qemu`/`qemu-esp` targets in > `Makefile.starkernel`. Both now report `GOP: linear framebuffer found` at 800×600 (vs. > amd64's 1280×800 via `q35`'s implicit default device — no code assumes a fixed > resolution, so this required no changes elsewhere). Re-ran the same cube test on both: > `fb/aarch64/cube-4.3.4.png` and `fb/riscv64/cube-4.3.4.png`, both correct. Re-ran the > standard three-arch acceptance boot afterward — all still clean, `dict_hash` identical > across all three and unchanged from before this fix (a QEMU device flag, not a kernel > change). > > **This is the checkpoint** — 4.3.x groundwork stops here for review per this item's own > acceptance criterion, before scoping whatever comes next. - [x] **4.3.5 — amd64: I/O APIC bring-up + i8042 keyboard, interrupt-driven.** No I/O APIC driver exists in this tree today (checked: `apic.c` is Local-APIC-only, and the timer needs no routing because it self-interrupts) and `pic_disable()` masks the legacy 8259 permanently — so no legacy IRQ, IRQ1 included, currently has any path to the CPU. This item stands up a minimal I/O APIC driver (MMIO base, redirection table entry for IRQ1 targeting a chosen vector and the boot CPU's LAPIC ID), programs the i8042 controller for interrupt mode, and installs an ISR that reads the scancode from port `0x60` and issues `apic_eoi()`. **No polling of the i8042 status port (`0x64`) anywhere in this path** — that is the whole point of doing this before the REPL keyboard work, not after. *Done when:* a keypress in QEMU on amd64 produces a captured scancode via the interrupt path with no polling loop in the code, three-arch QEMU boot unaffected, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** New `src/starkernel/arch/amd64/ioapic.c`/`include/starkernel/ > ioapic.h`: parses the real ACPI MADT (RSDP → XSDT → APIC table, same walk `pci.c` > already uses for MCFG, not shared code but the same technique) for the I/O APIC's MMIO > base and any Interrupt Source Override entries — nothing hardcoded. New `i8042.c`/ > `i8042.h`: minimal controller init, a trivial ISR (read `0x60`, push to a small ring > buffer, nothing else), and `i8042_pop_scancode()` for non-interrupt-context draining. > New diagnostic word `KBD-SCAN ( -- c -1 | 0 )` in `src/word_source/keyboard_words.c` > (raw hardware-boundary primitive, same relationship to future Console policy that > `PLOT` has to `capsules/fabric.4th`) plus a standing `KBD-DEBUG ( -- isr_count > spurious_count )` diagnostic. `apic_id()` added to `apic.h`/`apic.c` (LAPIC ID getter, > needed for the redirection entry's destination field). > > **Three real bugs found and fixed, all blocking this item's own acceptance test, not > scope creep:** > > 1. **LAPIC spurious-vector interrupts (`0xFF`) were previously fatal.** `pic_disable()` > means the I/O APIC is the *first* real external-interrupt source this kernel has ever > driven — the timer self-interrupts and never needed one. The very first live keypress > test crashed with `Vector : 255 (0xff)` / "Fault: Unhandled vector". Per Intel SDM > Vol.3 §10.9, a spurious-vector interrupt is a normal occasional hardware race, not a > fault, and must be silently ignored with no EOI. Fixed with a dedicated case in > `isr_common_handler()` (`interrupts.c`) plus a permanent `g_spurious_count` diagnostic > counter — cheap enough to keep, and this item is exactly why it's worth having. > > 2. **OVMF's own PS/2 driver leaves the keyboard *device* (not just the controller) with > scanning disabled.** After the crash was fixed, `KBD-SCAN` still came back empty on > every keypress. QEMU's own PS/2 tracepoints (`-trace enable='ps2_*,pckbd_*'`) showed > the device correctly generating and queuing the real make/break scancode sequence on > `sendkey`, so the device model itself was never the problem. `i8042_init()` only > programmed the *controller's* config byte (IRQ1-enable); it never told the *device* > to resume scanning. Fixed by sending `0xF4` (enable scanning) to the device via the > data port after the controller config write, with a bounded wait for the `0xFA` ACK — > a one-time init handshake, not the forbidden steady-state polling. (Ancillary, > reported not fixed: the controller's IRQ1-enable bit was already set before this > kernel ever touched it on this QEMU/OVMF combination, per the trace — that write is a > no-op here but stays correct for hardware that doesn't pre-enable it.) > > 3. **The real bug, found via `-d int` + a live LAPIC ISR/PPR register dump: `isr.S`'s > stub table only ever had individually-numbered stubs for vectors 0–32.** Vectors > 33–255 all shared `isr_stub_default`, which unconditionally pushes `255` as "the > vector" regardless of which IDT slot actually fired — nothing before this item had > ever needed a real interrupt source above vector 32. The I/O APIC correctly delivered > IRQ1 to vector 33 (0x21) — confirmed directly by reading the LAPIC's own ISR register > at the moment of the spurious firing, which showed vector 33 genuinely in-service — > but `isr_common_handler()` only ever saw vector 255, treated a real keyboard IRQ as > spurious, and (correctly, per finding 1) skipped its EOI, permanently stranding > vector 33's ISR bit and silently blocking that interrupt class forever after. A > priority-class theory (moving the keyboard vector to `0x31`, a different class from > the timer's `0x20`) was tested and ruled out first — the direct ISR-register readback > is what actually found it. Fixed by adding a dedicated `isr_stub33` (same `ISR_NOERR` > macro pattern as every other named stub) and pointing `isr_stub_table[33]` at it > instead of the shared default. > > **Verified live on amd64** via QEMU's HMP `sendkey` monitor command against a real > interactive serial session (same manual-injection technique used throughout 4.3.x): > `sendkey a` → `KBD-SCAN .S` showed `<2> 30 -1` (30 = `0x1E`, the correct XT Set-1 make > code for 'A'); `sendkey b` → two `KBD-SCAN` calls showed `48 -1` then `176 -1` (`0x30` > make / `0x30|0x80` break for 'B', confirming the release-code bit-7 pattern). `KBD-DEBUG` > read `2 0` (two real IRQs serviced, zero spurious) after each keypress. Three-architecture > acceptance boot clean on all three (`logs/20260808-001508` amd64, `logs/20260808-001546` > aarch64, `logs/20260808-001637` riscv64) — the feature is amd64-only this item, so > aarch64/riscv64 are a regression check, not a keyboard test. - [x] **4.3.5a — riscv64: minimal paging/addressing bring-up (prerequisite, blocks 4.3.5b).** Discovered live 2026-08-08 while implementing 4.3.5b, not anticipated when 4.3.5a-4.3.5f were originally scoped. **riscv64 has no software-controlled paging at all.** `arch/riscv64/arch.c`'s own comment is explicit: "Full Sv39/Sv48 page-table setup (SATP, PMP, etc.) is deferred to a later milestone" — it is a stub, `satp` is never written by this kernel. Separately, `memory/vmm.c` (header comment: "4-level paging Virtual Memory Manager (x86_64)") runs its `vmm_init()` unconditionally on all three architectures and builds a real x86-64-style page table in memory, but its activation step (`load_cr3()`) compiles to a no-op outside `__x86_64__` — so on riscv64 that table is dead, unused data. Whatever `satp` firmware (EDK2 RISC-V) left active at `ExitBootServices()` is what's live for the kernel's entire lifetime, and there is no documented or safe way for us to add entries to firmware's own page table post-handoff. This surfaced because 4.3.5b's PLIC threshold register (`PLIC_BASE + 0x201000`, deep into the PLIC's 0x600000-byte MMIO window) is not covered by firmware's mapping — confirmed via a live store-page-fault (`scause=0xF`) at exactly that address, not inferred. **Not fixed as part of surfacing it** — Captain Bob's ruling 2026-08-08: stop, scope as its own item, decide the approach (Bare-mode `satp=0` vs. real Sv39 bring-up) in a future session with fresh context, rather than deciding it embedded inside a PLIC item. 4.3.5b's driver code (`plic.c`/`plic.h`, `interrupts.c`/`apic.c` wiring, `Makefile.starkernel`) exists, written and reviewed, but **uncommitted** — it cannot be verified until this item unblocks it, and 4.3.5b's own acceptance ("three-arch QEMU boot unaffected") is currently violated (riscv64 hangs in the fatal exception handler). *Done when:* riscv64 has some working address-translation story (either genuinely activated Sv39 paging with a kernel-owned page table, or an explicit, deliberate switch to Bare mode) sufficient for `plic_init()`'s existing MMIO writes to succeed without faulting, three-arch QEMU boot clean, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** Chose Bare mode over Sv39 bring-up: this kernel builds no riscv64 > page table of its own (`vmm.c`'s is x86-64-shaped and never activated here) and has no > present use for virtual memory on this ISA, so there was no reason to build one just to > patch the one confirmed hole. Verified live before touching `satp`, not assumed: added a > read-only diagnostic to `arch_early_init()` (`arch/riscv64/arch.c`, already called at > `kernel_main.c:401`, ahead of both `pmm_init()` and `vmm_init()`) printing `satp`'s > MODE/PPN and `__kernel_start`'s address. Result — `satp.MODE = 0xa`: firmware (EDK2 > RISC-V) leaves **Sv57** active at kernel entry, not Sv39/Sv48 as this file's own stub > comment assumed (that comment was stale; corrected in the same edit). `__kernel_start` > (`0xbdd56982`) landed inside the UEFI-reported total physical RAM window > (`0x80000000`–`0x80000000+1020MB`), near the top past the PMM-free region — consistent > with the kernel's own running range being identity-mapped, the load-bearing assumption > for the fix's safety. Fix: `csrw satp, x0` + `sfence.vma` in one `asm volatile` block > (RISC-V Privileged Spec §4.2.1 ordering — must not be separated by a compiler-scheduled > memory access), placed in `arch_early_init()` right after the diagnostic prints, which > are now kept as a permanent boot record rather than reverted. > > Two-boot verification, per §25.0 rule 6 (acceptance is not "should work"): first a > diagnostic-only riscv64 boot confirmed the satp/identity-mapping facts above > (`logs/20260808-101931/riscv64/`, still hits the original `scause=0xF` fault since the > fix wasn't written yet); then, with the fix added, a second riscv64 boot confirmed > `plic_init()`'s `PLIC_THRESHOLD` write at `0x0c201000` now succeeds with zero exceptions > (`logs/20260808-110152/riscv64/`) — this is the already-written, still-uncommitted 4.3.5b > PLIC driver code in the working tree, exercised live but not part of this commit. > Three-architecture acceptance boot clean, zero exceptions on any: amd64 > (`logs/20260808-110252/amd64/`), aarch64 (`logs/20260808-110345/aarch64/`), riscv64 > (`logs/20260808-110500/riscv64/`, `PLIC: init` line present, no fault). > > **Reported, not fixed (§25.0 rule 3):** `arch_mmu_init()` is declared and defined on all > three architectures but `kernel_main.c` never calls it anywhere — dead code tree-wide, > pre-existing, unrelated to today's fix (which lives in `arch_early_init()` instead, > specifically to avoid routing a riscv64-only change through a shared call site that would > newly execute on amd64/aarch64 too). Also: the uncommitted 4.3.5b driver code's own > comments still label itself "item 4.3.5a" — stale from before this item was inserted > ahead of it; needs a rename to "4.3.5b" before that commit lands, not this one's job. - [x] **4.3.5b — riscv64: PLIC bring-up (external interrupt controller).** Does not exist anywhere in this tree — Phase 0 (0.2/0.3) only ever enabled the S-mode *timer* interrupt (`sie.STIE`); external interrupts (`sie.SEIE`, bit 9) were never touched, and `interrupts.c`'s own comment ("the PLIC replaces both") describes the architecture, not anything implemented. This item enables `sie.SEIE`, programs the PLIC's per-source priority, the hart context's threshold and enable bits, and wires the trap handler's external-interrupt case to `PLIC claim` → dispatch → `PLIC complete`. Prerequisite for 4.3.5c; no keyboard code in this item. **Blocked on 4.3.5a** (see that item) — driver code already written, not yet verifiable. *Done when:* a synthetic/known external interrupt source claims and completes correctly through the PLIC on riscv64, three-arch QEMU boot unaffected, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** Unblocked by 4.3.5a. The driver code itself (`plic.c`/`plic.h`, > `apic.c`/`interrupts.c` wiring) predates this item's own insertion point in the sequence > — its comments still said "item 4.3.5a"; corrected to "4.3.5b" (and the two > forward-references to "a future consumer, 4.3.5b" corrected to "4.3.5c", since 4.3.5b is > this item, not the consumer) as part of this commit. PLIC itself has no software > set-pending register (SiFive PLIC-1.0.0, unlike GICv2's `ISPENDR` — pending bits are > hardware-line-driven only), so "synthetic/known" here meant a real, already-present > hardware source: the board's UART (PLIC source 10, per `plic.c`'s own DTB-verified header > comment), driven through its standard NS16550 loopback mode (`UART_MCR_LOOP`, register > offsets/bits confirmed against this build host's `/usr/include/linux/serial_reg.h`, not > recalled) so TX loops back to RX internally — self-contained, deterministic, no external > host-side synchronization, and no race against `repl.c`'s later `console_getc()` polling > since the whole test ran and fully restored UART state before > `arch_enable_interrupts()`'s one real, permanent enable (`kernel_main.c:664`). > > Verified live, not assumed: pre-test `MCR` read back as `0x03`, not `0` — confirms > reading and restoring actual state mattered rather than assuming firmware left it clear. > Result: `claim_count=1`, `last_irq=0xa` (10, the UART), `iir=0xc4` (masks to `0x04` — > `UART_IIR_RDI`, confirming the genuine cause was receive-data, not a fluke or a different > source), `byte=0x55` (exact match to the byte written) — PASS > (`logs/20260808-111711/riscv64/`). Per Captain Bob's ruling 2026-08-08, the self-test > itself (the loopback toggle, the temporary `g_plic_last_irq`/IIR/byte-capture globals, > and its call site in `apic_init()`) was written, run once to capture this evidence, then > fully reverted — same treatment as the aarch64 `GICD_ITARGETSR` probe (item 4.3.5d's > note). Only the permanent substrate remains: `plic_init()` (threshold=0, `sie.SEIE` > enabled, no source enabled — `apic.c`'s own comment already stated that boundary) and the > standing `g_plic_claim_count` diagnostic that predates this item. Three-architecture > acceptance boot clean, zero exceptions on any, in this final reverted state: amd64 > (`logs/20260808-112015/amd64/`), aarch64 (`logs/20260808-112108/aarch64/`), riscv64 > (`logs/20260808-112235/riscv64/`, `PLIC: init` line present, no fault). - [x] **4.3.5c — riscv64: virtio-keyboard-pci, interrupt-driven.** New driver under `src/starkernel/virtio/` for the virtio-input device class — decode `EV_KEY` events off the input event virtqueue. Unlike `virtio_blk.c`'s synchronous poll-the-used-ring pattern, this device must be interrupt-driven end to end: the used-ring notification arrives via the PLIC path from 4.3.5b, not a poll loop. Built on 4.3.5b. **Amended 2026-08-08 — transport was wrong.** Originally scoped as "MMIO virtio, matching the existing MMIO virtio-blk pattern on this board" — checked, not assumed, and that pattern doesn't exist: the Makefile's own riscv64 `qemu` target comment says "virtio-blk-pci GPT disk" and uses `-device virtio-blk-pci,...,addr=0x1`/`addr=0x2` — PCI transport, not MMIO. `virtio-mmio`/`VIRTIO_MMIO` appear nowhere in this tree. `pci.c` already supports riscv64 via ECAM with its own fallback base (`g_ecam_base = 0x30000000ULL`). Corrected to PCI transport (`virtio-keyboard-pci`) — the same device class 4.3.5e uses on aarch64, which means this item and 4.3.5e now share most of the actual virtio-input driver logic (capability walk, feature negotiation, event-queue handling, ISR-status read), differing only in interrupt routing (PLIC vs. GIC). Concrete steps: 1. Add `-device virtio-keyboard-pci,addr=0x3` to the riscv64 `qemu`/`qemu-esp` targets in `Makefile.starkernel` — explicit `addr=`, matching this target's existing precedent for the two `virtio-blk-pci` drives (`addr=0x1`/`addr=0x2`), so the PCI slot (and therefore the PLIC source, see below) is deterministic rather than left to QEMU's auto-assignment. 2. New `include/starkernel/virtio_input.h` / `src/starkernel/virtio/virtio_input.c` — same device identity, event structure, and design questions as 4.3.5e's own plan (vendor `0x1AF4`, device ID `0x1052`, `struct virtio_input_event`, `EV_KEY = 0x01`, all from this build host's `/usr/include/linux/virtio_ids.h`/`virtio_input.h`/ `input-event-codes.h`). **Resolved here, carries forward to 4.3.5e:** don't refactor `virtio_blk.c`'s `static walk_virtio_caps()` into shared code — duplicate a small version into `virtio_input.c` instead. Keeps this item's diff to new files only and leaves the working, tested `virtio_blk.c` untouched. 3. Compute the target PLIC source at **runtime**: `pin = pci_read8(dev, PCI_CFG_INT_PIN)` (needs the same new `PCI_CFG_INT_PIN` = `0x3D` constant 4.3.5d adds to `pci.h` — add it here if 4.3.5d hasn't landed first), `slot = dev->device`, `source = 32 + ((slot + pin - 1) % 4)` → PLIC sources 32–35. Derived live from this host's QEMU 10.2.1 riscv64 `virt` DTB, not recalled — full decode in §27.5.2. `plic_set_priority()`/ `plic_enable()` take this source directly (PLIC's interrupt specifier is one bare cell, no separate type/flags field the way GIC's SPI encoding needs). 4. Same `pci_enable()` interrupt-disable-bit gap 4.3.5e flags (`pci.c:363` never clears PCI COMMAND bit 10) — check it here too, first, since this item lands before 4.3.5e. 5. Same mandatory ISR-status read as 4.3.5e (`VIRTIO_PCI_CAP_ISR_CFG`, level-triggered per the decoded `interrupt-map` — see §27.5.2) — skipping it leaves the PLIC source's pending condition latched. 6. Wire `riscv64_interrupt_handler()`'s `SCAUSE_S_EXTERNAL` branch (`interrupts.c`) to dispatch the computed PLIC source to the new driver's ISR, extending the generic claim/complete dispatch 4.3.5b built — same shape as i8042's branch in amd64's `isr_common_handler()`, per that dispatch function's own doc comment. *Done when:* a keypress in QEMU on riscv64 (via `sendkey`, same manual-injection technique as 4.3.5's amd64 verification) produces a captured `EV_KEY` event through the interrupt path with no polling loop anywhere in the path, three-arch QEMU boot unaffected, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** New `include/starkernel/virtio_input.h` / `src/starkernel/virtio/ > virtio_input.c`: capability walk duplicated from `virtio_blk.c` (per the resolved design > question above), status/feature negotiation mirroring `vblk_init_device()`, eventq > (queue 0) with `EVENTQ_SIZE`=8 pre-posted device-writable buffers re-posted after each > drain — no synchronous wait anywhere, unlike `vblk_io()`. `enable_interrupt_route()` is > arch-guarded (`#if defined(ARCH_RISCV64)` computes and enables the PLIC source per > §27.5.2's formula; the `#else` path reports rather than silently no-ops, so a future arch > using this file without adding its own routing fails loudly instead of a permanently- > pending, never-enabled source). `PCI_CFG_INT_PIN` (`0x3D`) and `PCI_CMD_INTX_DISABLE` > (bit 10) added to `pci.h` — the latter checked and cleared explicitly, since > `pci_enable()` never touches it. `virtio_input_isr()` reads `VIRTIO_PCI_CAP_ISR_CFG` > before draining (mandatory, not optional — the routed source is level-triggered). > `arch/riscv64/interrupts.c`'s `SCAUSE_S_EXTERNAL` branch now dispatches to it when the > PLIC claim matches `g_virtio_input_plic_source`, the first real per-source consumer of > 4.3.5b's substrate. `kernel_main.c` calls `virtio_input_find_keyboard()` unconditionally > right after the Artemis `virtio_blk_find_artemis()` call, same pattern. > > New FORTH words in `src/word_source/keyboard_words.c` (extending the same file 4.3.5's > `KBD-SCAN`/`KBD-DEBUG` already use, riscv64-branch added alongside the existing amd64 > one, no-op elsewhere): `VKBD-EVENT ( -- code value -1 | 0 )` pops one decoded `EV_KEY` > event off the interrupt-fed ring; `VKBD-DEBUG ( -- isr_count )` is the standing > diagnostic. Distinct words from `KBD-SCAN`/`KBD-DEBUG` rather than shared ones — different > device, different event shape; convergence is 4.3.5f's explicit job, not this item's. > > Verified live with a real keypress, not synthetic: QEMU HMP `sendkey a` (temporary > monitor socket added to the Makefile for this test, reverted after — same treatment as > every other one-shot verification harness this session) while a second connection typed > `VKBD-EVENT . . . CR VKBD-DEBUG . CR` over the serial socket. Result: `-1 1 30` (flag, > value, code — `code=30` is `KEY_A` exactly, `value=1` is a press, per this build host's > `/usr/include/linux/input-event-codes.h`) then `2` (`VKBD-DEBUG`, confirming two real > interrupts serviced, not a poll artifact). Zero exceptions > (`logs/20260808-114907/riscv64/qemu-riscv64-20260808-114907-sendkey-verify.log`). > > Three-architecture acceptance boot clean (standard `make qemu`, no monitor socket, no > regression from the new dispatch/driver code): amd64 (`logs/20260808-114435/amd64/`), > aarch64 (`logs/20260808-114603/aarch64/`), riscv64 (`logs/20260808-114720/riscv64/`, > `virtio-input: found device` / `driver ready` present, no fault). - [x] **4.3.5d — aarch64: GIC SPI wiring for virtio-input.** Item 0.6 scoped the GIC to "one interrupt" (the timer PPI) on purpose and explicitly called a general GIC driver out of scope. A PCI-attached `virtio-keyboard-pci` device signals via legacy INTx, which is an SPI, not a PPI — a different GIC distributor path (SPI target-CPU and priority registers) that today's minimal init never touches. This item extends the GIC init from 0.6 just far enough to enable and route one SPI, still not a general driver — scope stays as narrow as 0.6's did. **Amended 2026-08-08 — original acceptance was circular.** "Take and acknowledge an SPI from the virtio-input PCI device" can't close as its own commit: that device doesn't exist until 4.3.5e, so this item could never be verified in isolation, breaking §25.0's one-item-one-commit rule. Fixed the same way 4.3.5b already fixed the identical problem for the PLIC — verify against a **synthetic/known** interrupt, not the real device. Concrete steps: 1. Add `PCI_CFG_INT_PIN` (offset `0x3D`, byte — the Interrupt Pin register) to `include/starkernel/pci.h`, alongside the existing `PCI_CFG_INT_LINE` (`0x3C`). 2. Compute the target SPI at **runtime**, not a hardcoded constant: `pin = pci_read8(dev, PCI_CFG_INT_PIN)` (1=INTA…4=INTD), `slot = dev->device` (already populated by `pci_find_first()`), `spi = 3 + ((slot + pin - 1) % 4)` → INTID `32 + spi`. Derived live from this host's QEMU 10.2.1 `virt` DTB, not recalled — full decode and the table it produced are in §27.5. 3. Program, for the computed `intid`: `GICD_IPRIORITYR` at `0x400 + intid` (value `0x80`, matching `TIMER_PRIORITY`); `GICD_ISENABLER` at `0x100 + 4*(intid/32)` (`0x104` for every candidate INTID 35–38), bit `intid % 32`; `GICD_ITARGETSR` at `0x800 + intid` (`0x823`–`0x826`), value `0x01` (single vCPU — this target has no `-smp`). `GICD_ICFGR`: read back first; §27.5's decode says this SPI should already default level-triggered, write only if the readback disagrees — keep item 0.6's "don't touch ICFGR unless forced to" posture. 4. Verify with **no device present**: software-pend the computed SPI via `GICD_ISPENDR` (offset `0x200 + 4*(intid/32)`, bit `intid % 32` — standard GICv2 architecture register, ARM IHI 0048B), confirm it is taken, read via `GICC_IAR`, and completed via `GICC_EOIR` through `aarch64_irq_handler()`. This is the actual acceptance below. *Done when:* a software-pended SPI (via `GICD_ISPENDR`, no device present) on the computed target INTID is taken and acknowledged (`IAR`/`EOIR`), three-arch QEMU boot unaffected, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** New `apic_spi_enable(uint32_t intid)` in `apic.c` — generalises > item 0.6's PPI-only priority/enable/target sequence to one explicit SPI: `GICD_IPRIORITYR` > (byte-indexed, same pattern as the timer PPI), `GICD_ISENABLER` at > `0x100 + 4*(intid/32)`, `GICD_ITARGETSR` (byte-indexed, CPU 0 only — no `-smp` on this > target), and `GICD_ICFGR` read-then-write-only-if-needed (kept item 0.6's "don't touch > ICFGR unless forced to" posture — the readback already matched level-triggered on this > board, so no write occurred in practice). New `GICD_ITARGETSR`/`GICD_ISPENDR` offset > constants added alongside the existing `GICD_*` block. > > Verified with a temporary self-test (`aarch64_gic_spi_self_test()` in `interrupts.c`, > called once from `apic_init()`): software-pended target INTID 38 (GIC SPI 6 — the slot > 3/INTA formula from §27.5.1, matching 4.3.5c's riscv64 `addr=0x3` placement) via > `GICD_ISPENDR`, confirmed taken and EOI'd through the existing generic > `aarch64_irq_handler()` dispatch (which needed **no changes** — it already EOIs any > non-spurious, non-timer INTID unconditionally, unlike riscv64's PLIC which needed an > explicit per-source branch in 4.3.5c). > > **One real bug found and fixed, not scope creep:** first self-test run reported FAIL — > `target_intid=0x26 last_intid=0x00`, no exception, boot continued normally past the > bounded spin-wait (`logs/20260808-115636/aarch64/`). Root cause: `PSTATE.I` is still set > at the point `apic_init()` (M4) runs — `arch_enable_interrupts()` (`msr daifclr, #2`) > doesn't run until much later (M7 area, `kernel_main.c`), so the GIC correctly latched the > pended SPI but the core never trapped to take it. Same class of fix riscv64's self-test > needed for `sstatus.SIE`. Fixed by temporarily clearing `PSTATE.I` (`msr daifclr, #2`) > around the pend-and-wait, restoring it after (`msr daifset, #2`) — matches the pre-test > state exactly, `arch_enable_interrupts()`'s later call is unaffected. Rerun after the fix: > `target_intid=0x26 last_intid=0x26`, PASS, zero exceptions > (`logs/20260808-115821/aarch64/`). > > Self-test code (the function, its `g_gic_last_intid` capture in `interrupts.c`, and its > call site in `apic_init()`) reverted after recording this result — `interrupts.c` has > **zero net diff** from before this item; only `apic.c`'s permanent `apic_spi_enable()` > remains, unused/uncalled until 4.3.5e wires a real device to it. Three-architecture > acceptance boot clean, zero exceptions: amd64 (`logs/20260808-120048/amd64/`), aarch64 > (`logs/20260808-120155/aarch64/`), riscv64 (`logs/20260808-120258/riscv64/`) — riscv64 > reboot required by §25.0 rule 6 / `.claude/CLAUDE.md`'s acceptance rule even though this > item touched only aarch64 files. > **Scoping check, 2026-08-08 — no landmine found (unlike 4.3.5a).** Before starting this > item, checked whether aarch64 carries the same class of gap that blocked riscv64: > `arch_mmu_init()` (`arch/aarch64/arch.c:169`) is an identical stub — "page-table setup... > deferred to a later milestone" — so aarch64's MMIO likewise depends entirely on whatever > EDK2 left mapped at `ExitBootServices()`, an assumption `apic.c`'s own header comment > (lines 76–79) already names. Live-tested rather than inferred, same rigor as 4.3.5a's > discovery: added a temporary probe (read-modify-write to `GICD_ITARGETSR[32]`, offset > `0x820`) immediately after 0.6's existing `GICD_CTLR`/`ISENABLER0`/`IPRIORITYR` writes, > booted aarch64 alone in QEMU. Result: survived — `GICv2 probe: ITARGETSR[32] read+write > survived` printed, boot proceeded clean to `ok>`, no exception (log: > `logs/20260808-093228/aarch64/`). `0x820` isn't itself one of this item's real targets — > those, per the DTB decode above, are INTID 35–38 at `0x823`–`0x826` — but all of them sit > in the same 4KB page as `0x820` and as the offsets item 0.6 already proves reachable on > every boot (`0x000`/`0x100`/`0x400`), so the probe validates the page, not a coincidence. > Unlike riscv64's PLIC threshold register, which sat 2MB deep in a part of its MMIO window > nothing had ever touched, that page-adjacency is why this item doesn't carry a > 4.3.5a-shaped prerequisite. 4.3.5e's PCI-BAR MMIO transport is likewise already proven > live (`virtio-blk-pci` uses it today on this same board). Probe code reverted after > recording this result — not committed; this item remains unstarted. - [x] **4.3.5e — aarch64: virtio-keyboard-pci, interrupt-driven.** Add `virtio-keyboard-pci` to the aarch64 `qemu`/`qemu-esp` targets — the existing `virtio-blk-pci` device proves the PCI bus is already enumerated (`pci.c`) on this board, so this rides the same bus, a new device class. Same virtio-input driver shape as 4.3.5c (shared code where the transport allows — MMIO vs. PCI config-space discovery differs, the event-queue/`EV_KEY` decode should not). Built on 4.3.5d. Concrete steps: 1. Add `-device virtio-keyboard-pci` to the aarch64 `qemu`/`qemu-esp` targets in `Makefile.starkernel`. Confirmed available in this host's QEMU 10.2.1 (`qemu-system-aarch64 -device help` lists it, bus PCI, alias `virtio-keyboard`). 2. New `include/starkernel/virtio_input.h` / `src/starkernel/virtio/virtio_input.c`. Device identity: vendor `0x1AF4` (same as blk), device ID `0x1052` — the modern-ID formula `0x1040 + VIRTIO_ID_INPUT`, `VIRTIO_ID_INPUT = 18` per this build host's `/usr/include/linux/virtio_ids.h`. No legacy/transitional ID exists for virtio-input (unlike blk's `0x1001` fallback — input postdates the legacy 0.9.5 spec), so `pci_find_first()` only needs the one modern ID, no fallback branch. 3. Event structure, from this host's `/usr/include/linux/virtio_input.h` and `/usr/include/linux/input-event-codes.h` (authoritative, not recalled): `struct virtio_input_event { __le16 type; __le16 code; __le32 value; }` (8 bytes), `EV_KEY = 0x01`. 4. **Resolved by 4.3.5c, 2026-08-08:** duplicate `walk_virtio_caps()` into `virtio_input.c` rather than promoting it out of `virtio_blk.c` into shared code — decided there since 4.3.5c lands first and needs the identical walk. This item's `virtio_input.c` is riscv64/4.3.5c's file plus the GIC-specific interrupt routing below; no separate capability-walker decision needed here. 5. `pci_enable()` (`pci.c:363`) sets only IO/MEM/BUS_MASTER in the PCI COMMAND register — it never clears bit 10 (Interrupt Disable). If that bit is set at enumeration, INTx never asserts and every earlier step passes while producing zero real interrupts — same failure shape as 4.3.5's OVMF-scanning-disabled bug (finding 2 in that item's done note). Read COMMAND back after `pci_enable()` and confirm/clear bit 10. 6. Device init follows `vblk_init_device()`'s sequence (`virtio_blk.c:361-499`) almost exactly — reset → ACKNOWLEDGE → DRIVER → negotiate `VIRTIO_F_VERSION_1` → FEATURES_OK → configure queue(s) → DRIVER_OK. Differences: - Two virtqueues (eventq index 0, statusq index 1) — read `common->num_queues` back from the device rather than hardcoding 2, same live-verification discipline as everything else in this item. - Unlike `vblk_io()`'s request/response pattern, the driver pre-posts N empty `virtio_input_event` buffers (device-writable) into the eventq's avail ring at init and does not wait synchronously — the device fills and posts a buffer to the used ring asynchronously, signalled by the interrupt wired in 4.3.5d, not a poll loop. Buffers must be re-posted after being drained in the ISR. - statusq (index 1) is for driver→device reports (LED state) — not needed to read keypresses; configure it (spec expects both queues set up) but leave it unused. - **New for this tree, mandatory:** `virtio_blk.c` defines `VIRTIO_PCI_CAP_ISR_CFG = 3` but never reads it — `vblk_io()` polls and never handles a real interrupt, so it never needed to. This item is the first interrupt-driven virtio-pci device here. Because the routed SPI is level-triggered (§27.5's decoded `interrupt-map` flags = `0x4`), the ISR handler **must** read the ISR-status capability byte (`walk_virtio_caps(pci, VIRTIO_PCI_CAP_ISR_CFG, NULL)`) — that read is what deasserts the line. Skipping it leaves INTx asserted after the first event: an interrupt storm or a permanent hang, not a subtle bug. - Report, don't fix: `virtio_blk.c`'s `wmb()`/`rmb()` (`virtio_blk.c:221-226`) are compiler barriers only (`asm volatile("" ::: "memory")`), no real memory fence. Adequate for blk's synchronous polled loop; an interrupt-driven used-ring (device writes concurrently with the driver's re-post loop) leans on this ordering harder. Not this item's job to fix tree-wide — report if it manifests as a live symptom. *Done when:* a keypress in QEMU on aarch64 (via `sendkey`, same manual-injection technique as 4.3.5's amd64 verification) produces a captured `EV_KEY` event through the interrupt path with no polling loop anywhere in the path, three-arch QEMU boot unaffected, log committed. *Refs:* §27.5. > **Done, 2026-08-08.** `virtio_input.c`'s `enable_interrupt_route()` gained an > `#elif defined(ARCH_AARCH64)` branch alongside 4.3.5c's riscv64 one — same slot/pin > read (`PCI_CFG_INT_PIN`), the §27.5.1 formula (`spi = 3 + ((slot+pin-1) % 4)`, INTID = > `32 + spi`) instead of riscv64's §27.5.2 one, calling `apic_spi_enable()` (item 4.3.5d) > instead of `plic_enable()`. New `g_virtio_input_gic_intid` global, same "0 is a safe > sentinel" reasoning as riscv64's `g_virtio_input_plic_source` (SPIs start at INTID 32, > never 0). Everything else in the file — capability walk, feature negotiation, eventq > pre-posting, the mandatory ISR-status read — is unchanged, exactly as §27.5.1's original > amendment predicted ("this item and 4.3.5e now share most of the actual virtio-input > driver logic"). `aarch64_irq_handler()` (`interrupts.c`) dispatches to `virtio_input_isr()` > **before** its `GICC_EOIR` write, same claim-dispatch-complete ordering riscv64 uses — > required here for a real reason: EOI'ing a still-asserted level-triggered line first > would let the GIC immediately re-signal it. `keyboard_words.c`'s `VKBD-EVENT`/`VKBD-DEBUG` > guards extended to `ARCH_AARCH64` (previously riscv64-only) — both words already used > arch-agnostic `virtio_input.c` symbols, so this was a guard change only, no new logic. > `-device virtio-keyboard-pci,addr=0x3` added to the aarch64 `qemu` target (the item's > text also named `qemu-esp`, but that target has no virtio-blk-pci/Artemis disk either — > not part of §25.0's acceptance boot, skipped as a report-not-fix imprecision in the > original item wording, not a deviation from anything load-bearing). > > Verified with a real keypress, same technique as 4.3.5c: temporary monitor socket > (reverted after), QEMU HMP `sendkey a`, `VKBD-EVENT . . . CR VKBD-DEBUG . CR` over the > serial socket. Result: `-1 1 30` (`code=30` = `KEY_A` exactly, `value=1` = press) then > `2` (two real interrupts serviced) — identical to riscv64's result, first try, no > equivalent of 4.3.5d's DAIF bug (this path runs after `arch_enable_interrupts()`, unlike > that item's self-test which deliberately ran before it) > (`logs/20260808-122317/aarch64/qemu-aarch64-20260808-122317-sendkey-verify.log`). > > **Found incidentally, reported not fixed:** after the verification above completed > cleanly, sending `BYE` to the same session produced `*** EXCEPTION (aarch64) ***` > (`ESR_EL1=0x02000000`, EC=0/"unknown reason") in `arch_cold_reset()`'s cold-restart path > — same log, lines after the passing test output. Confirmed unrelated to this item > (`arch.c`'s reset path, nothing this item touched) and confirmed not a regression this > item introduced: riscv64's equivalent `BYE`-terminated session > (`logs/20260808-114907/riscv64/`) shows no exception at the same point. Nobody had > exercised `BYE` from a monitored serial session before either verification run this > session, which is why this surfaced only now. Not this item's job to fix. > > Three-architecture acceptance boot clean (standard `make qemu`, no monitor socket, `BYE` > never sent): amd64 (`logs/20260808-122554/amd64/`), aarch64 (`logs/20260808-122640/ > aarch64/`, `virtio-input: found device` / `driver ready` present), riscv64 > (`logs/20260808-122743/riscv64/`, unaffected — this item's riscv64-side changes were > doc-comment-only). - [x] **4.3.5f — Checkpoint: one keyboard abstraction, three architectures, no polling.** Converge 4.3.5/4.3.5c/4.3.5e behind a single scancode/keycode interface so the REPL keyboard-input work (M8, outside Stadium) has one thing to call, not three. Stop and review here — same posture as 4.3.4 — before scoping REPL wiring. *Done when:* a keypress on all three architectures produces the same shape of event at the shared interface, confirmed with a live keypress test per architecture (not a synthetic/injected one — this is the first item in this slice where a real key matters), three-arch QEMU boot, logs committed. *Refs:* §27.5. > **Done, 2026-08-08. Keyboard-input slice (4.3.5–4.3.5f) complete.** New `KEY-EVENT > ( -- keycode pressed -1 | 0 )` in `keyboard_words.c`, alongside (not replacing) the > existing per-device diagnostics `KBD-SCAN`/`VKBD-EVENT`. `keycode` uses the Linux input > keycode namespace; `pressed` is 1 or 0. > > **The convergence needed almost no translation, for a reason worth recording rather than > just asserting.** Comparing this build host's own `/usr/include/linux/input-event-codes.h` > against 4.3.5's already-documented live scancode observations (`sendkey a` → 30, > `sendkey b` → 48) showed `KEY_A=30`/`KEY_B=48` match those XT Set-1 make codes exactly — > checked systematically across the full standard 84-key block (`KEY_ESC=1` through > `KEY_F10=68`), all identical to the historical XT/AT Set-1 numbering. Not a coincidence: > documented historical property of how Linux's input keycode namespace was originally > defined. So amd64's translation is two lines (`keycode = sc & 0x7F`, `pressed = !(sc & > 0x80)`) covering the entire non-extended key range, not a lookup table — riscv64/aarch64 > need no translation at all, `virtio_input_pop_event()`'s `code`/`value` already live in > the same namespace. Explicitly out of scope, matching this checkpoint's "one shared shape > proven live," not full coverage: extended (`0xE0`-prefixed) i8042 scancodes, virtio-input > autorepeat (`value=2`, folded into "still pressed" here) — deferred to the REPL wiring > item (M8) this checkpoint unblocks. > > Verified live on all three architectures with a real `sendkey a`, same technique as > 4.3.5/4.3.5c/4.3.5e — identical output shape everywhere, `-1 1 30` (flag, pressed, > keycode): amd64 (`logs/20260808-123405/amd64/qemu-amd64-20260808-123405-keyevent- > verify.log`), riscv64 (`logs/20260808-124059/riscv64/qemu-riscv64-20260808-124059- > keyevent-verify.log`), aarch64 (`logs/20260808-124207/aarch64/qemu-aarch64-20260808- > 124207-keyevent-verify.log`) — the exact "same shape of event... confirmed with a live > keypress test per architecture" this item's acceptance requires. Three-architecture > standard acceptance boot clean, zero exceptions: amd64 (`logs/20260808-124331/amd64/`), > aarch64 (`logs/20260808-124418/aarch64/`), riscv64 (`logs/20260808-124524/riscv64/`). > > This closes the keyboard-input slice opened at 4.3.5. Next per the 2026-08-07 sequencing > note below: glyph rendering (stroke-based font capsules), not yet scoped. > **Sequencing noted 2026-08-07, not yet scoped:** keyboard input (4.3.5-4.3.5f) → > glyph rendering (stroke-based font capsules) → REPL — in that order, before the 4.6 > Artemis boundary (renumbered from 4.4, then from 4.5, 2026-08-11 — REPL now has its own > 4.4 section, and Artemis moved down to make room for item 4.5's -O0 finding). > Recorded so the order is not lost between sessions; neither glyph > rendering nor REPL has *Done when* criteria yet. Per §25.0 rule 2, each gets scoped in > detail at its own checkpoint, not now. > **Landing point noted 2026-08-08, not yet scoped:** whatever 4.3.5f's shared interface > produces has to eventually feed the FORTH-79 terminal I/O words, not just sit behind a > new kernel API nothing calls. Checked, not assumed: `KEY`/`?TERMINAL`/`EMIT`/`EXPECT` > are already registered (`src/word_source/io_words.c:247-250`, `string_words.c:994`) — > this is the shared/vendored VM source, same words the hosted build has. In the kernel > build both are dead today: `io_word_key()` calls libc `getchar()`, which > `src/starkernel/vm/host/shim.c:1206` hardcodes to always return `-1` (EOF); `?TERMINAL` > (`io_words.c:100-108`) is its own literal stub — "Simple implementation - always return > false for now." So there is currently no keyboard path into the VM on any architecture, > confirmed by reading the code, not inferred from the milestone status. `EMIT`/`putchar()` > already routes to `console_putc()` (`shim.c:1100`) — only the input side is stubbed. > Not scoped now, per §25.0 rule 2 — recorded so it isn't lost before REPL wiring is > scoped. Whether the fix lands in `shim.c`'s `getchar()`, in `io_words.c` under > `#ifdef __STARKERNEL__`, or elsewhere is an open question for that later item, not > decided here. > > **Now scoped, 2026-08-11 — resolved in `docs/lithosananke/ROADMAP.md`'s M8 section, > not here.** M8 is "tracked outside Stadium" per §27.5, and its console-facing pieces > don't change this document's own scope, so the resolution lives there rather than as > new 4.3.x items. Short answer to the open question above: `io_words.c` under > `#ifdef __STARKERNEL__`, wired to the same merged serial+keyboard source the REPL uses > — see that section for the full design. - [x] **4.3.6 — Em-square glyph coordinate convention.** `EM-UNITS 1000` (baseline Y=0, x-height ≈500, cap-height ≈700, ascender ≈750, descender ≈-250), plus `EM-X`/`EM-Y`/ `G-LINE` scaling/translating em-square strokes into `CART-PLOT` screen coordinates via `*/`, parallel to 4.3.3's `PROJECT`. Prerequisite for every glyph-drawing word that follows. *Done when:* a normalized test shape scales/translates correctly onto raster coordinates at two different requested sizes, verified live via screendump against hand-calculated pixel positions. *Refs:* §27.6.1. > **Done, 2026-08-09.** Blocks 4916–4917 in `capsules/fabric.4th` (lint-clean per > `mkcapsule --lint`), landed there rather than a new file — 4.3.6d already names only > `fabric.4th` and the 4.3.6c font capsule as the two capsules wired into boot, and blocks > 4916+ were confirmed free across the entire block map, not just the `fabric.4th` family. > `1000 CONSTANT EM-UNITS` (the punch list's "`EM-UNITS 1000`" is prose shorthand, not > FORTH argument order — matches `fabric.4th`'s own `46341 CONSTANT COS45` precedent). > `EM-X`/`EM-Y`/`G-LINE` exactly as §27.6.1 specifies, no deviation. > > **Verified live on amd64**, not just non-crashing: `fabric.4th` isn't wired into > `init.4th`'s boot chain yet (that's 4.3.6d), so a temporary monitor+serial-injection > harness (built, run once, reverted — same pattern as 4.3.5b/4.3.5d's probes) manually > `EXEC`'d the capsule and called `G-LINE` at two sizes from a shared origin: `GSIZE 100` > (white, origin 200,300) and `GSIZE 50` (yellow, origin 500,100), each drawing one leg to > `1000 700` (positive em-y) and one leg to `300 -333` (**negative** em-y, deliberately > chosen non-multiple-of-1000 to exercise `*/`'s truncate-toward-zero behaviour the section > itself flags — `300*-333/1000` truncates to `-33`, not floor's `-34`). > > Screendump + exact pixel-bbox extraction (Python/PIL, not eyeballed) confirmed both > shapes landed exactly on hand-calculated raster coordinates: white bbox `(200,300, > 429,532)` against hand-calc `EM-X(0)=200, EM-X(1000)=300, EM-Y(700)→raster 429, > EM-Y(-333)→raster 532` — exact match on all four; yellow bbox `(500,550,664,715)` > against the size-50 hand-calc — exact match, confirming correct scaling at a second > size and correct truncation behaviour on the descender case. Screenshot (local only, > `fb/` is gitignored per 4.3.2): `fb/amd64/verify-4.3.6-20260809-184431.png`. > > Three-architecture acceptance boot clean, identical Stadium conservation on all three > (`resident_sum=43691 reservoir=21845 sum=65536`, matching item 4.2's baseline — > unaffected by this item, as expected): amd64 (`logs/20260809-184959`), aarch64 > (`logs/20260809-185037`), riscv64 (`logs/20260809-185127`). - [x] **4.3.6a — UTF-8 decoder.** `DECODE-UTF8`, composed in FORTH per the compose-in-FORTH-first rule — no existing UTF-8 decoding anywhere in this tree (checked). Must handle 1-byte and 2-byte sequences (ASCII, Latin-1) *and* 3-byte sequences (General Punctuation — confirmed load-bearing for v1, not future-i18n-only, since smart quotes/dashes/ellipsis live outside Latin-1). *Done when:* correctly decodes a test string mixing 1-byte ASCII, 2-byte Latin-1 (e.g. `°` U+00B0), and 3-byte General Punctuation (e.g. `—` U+2014) sequences, verified live against hand-computed expected codepoints. *Refs:* §27.6.2. > **Done, 2026-08-09.** Blocks 4918–4920 in `capsules/fabric.4th` (lint-clean). > `UTF8-SEQ-LEN`/`UTF8-CONT?` verbatim from §27.6.2. `DECODE-UTF8 ( addr u -- codepoint > addr' u' )` dispatches to one of four `UTF8-ASSEMBLE-N` helper words (1/2/3/4-byte, > all four formulas implemented since the spec text lists all four as what the word "must > compute," even though only 1/2/3 are required by this item's own acceptance) — factored > out because the single-word draft hit `mkcapsule`'s real limit, discovered here: **64 > chars × 16 lines per block**, not the looser 1024-byte framing used elsewhere in this > document. Same factoring pattern 4.3.3b already used for `LINE`/`LINE-SETUP`/etc. > Invalid lead bytes fall to `U+FFFD` (replacement character) advancing 1 byte, avoiding > an infinite loop on malformed input — not specified by §27.6.2, a minimal safety default > consistent with the word's own contract always returning a valid `(codepoint addr' u')`. > > Hand-verified the 2-byte and 3-byte formulas against the required test characters before > writing code: `°` U+00B0=176 via `0xC2 0xB0` (`(0xC2 AND 31) LSHIFT 6 OR (0xB0 AND 63)` = > `128 OR 48` = `176`); `—` U+2014=8212 via `0xE2 0x80 0x94` (`(0xE2 AND 15) LSHIFT 12 OR > (0x80 AND 63) LSHIFT 6 OR (0x94 AND 63)` = `8192 OR 0 OR 20` = `8212`) — both exact. > > **Verified live on amd64**, same temporary-probe pattern as 4.3.6 (built, run once, > reverted): a 6-byte test buffer (`'A' 0xC2 0xB0 0xE2 0x80 0x94`) decoded via three > sequential `DECODE-UTF8` calls, each printing its codepoint and re-feeding the returned > `(addr' u')` into the next call. Results: `65` (ASCII `A`), `176` (`°`), `8212` (`—`) — > exact match on all three, and the buffer's remaining length hit exactly `0` after the > third call, confirming the byte-consumption bookkeeping is correct, not just the > codepoint arithmetic. > > Three-architecture acceptance boot clean, Stadium conservation unchanged from item > 4.3.6's baseline (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 > (`logs/20260809-185758`), aarch64 (`logs/20260809-185835`), riscv64 > (`logs/20260809-185925`). - [x] **4.3.6b — Codepoint → glyph dispatch.** Bucketed `CASE`/`OF`/`ENDOF` chain (`DISPATCH-DIGIT`/`-UPPER`/`-LOWER`/`-ASCII-PUNCT`/`-LATIN1`/`-GENPUNCT`) routed by `DISPATCH-GLYPH` via `WITHIN` range checks, per §27.6.3 — chosen over a flat xt-table despite the table composing more cleanly with override (§27.6.5's finding). Every glyph word follows the `( -- em-advance )` contract; unmatched codepoints fall to `TOFU`. *Done when:* dispatch correctly invokes the right stroke word and returns its advance for at least one codepoint from each of the six buckets, and falls through to `TOFU` (not a crash) for a codepoint outside all six ranges. *Refs:* §27.6.3. > **Done, 2026-08-09.** Blocks 4921–4924 in `capsules/fabric.4th` (lint-clean). > `DISPATCH-GLYPH` and the six bucket words exactly as §27.6.3 specifies, plus `TOFU` > (empty box via four `G-LINE` calls, advance `500` per the section's own proposal) and > `DRAW-GLYPH ( codepoint x y size color -- adv )`. > > **Deliberately minimal, not the real glyph set.** The 113-glyph repertoire is item > 4.3.6c's scope, not this one's — building it now would be jumping ahead per §25.0 rule > two. Each of the six buckets got exactly one placeholder stroke word > (`G-TEST-DIGIT`/`-UPPER`/`-LOWER`/`-PUNCT`/`-LATIN1`/`-GENPUNCT`, obviously-temporary > names, each drawing one `G-LINE` and returning a distinct advance) — enough to prove the > routing mechanism without designing any real letterforms. 4.3.6c will need to add the > full `CASE` coverage to these same six bucket words; these placeholder entries are > expected to be superseded there, not treated as finished glyph art. > > **A real block-format limit found while landing this item, not previously hit:** > `mkcapsule`'s actual rule is **64 chars × 16 content lines per block**, and — not > previously noticed — a blank separator line between blocks is charged to the > *preceding* block's line count, not free. Item 4.3.6a's `DECODE-UTF8` block (4920) was > already at exactly 16 lines with zero slack, so appending this item's blocks after it > pushed it to 17 via the following blank line. Fixed by merging two adjacent, unrelated > lines in that block (`ULEN ! UADDR !` / `UADDR @ C@ ULEAD !` → one line) — a one-line > mechanical reformat with identical semantics, not a scope change; re-verified via the > `DECODE-UTF8` regression check below before relying on it. > > **Verified live on amd64**, same temporary-probe pattern as prior 4.3.6x items: a > regression check of `DECODE-UTF8` (`65`/`176`/`8212`, matching item 4.3.6a's own values > — confirms the block-4920 line merge changed nothing) followed by `DRAW-GLYPH` called > with one representative codepoint per bucket plus one out-of-range codepoint (`1`, a > control character outside all six `WITHIN` ranges). All seven results matched expected > exactly: digit `48`→`400`, upper `65`→`600`, lower `97`→`450`, punct `33`→`250`, latin1 > `176`→`550`, genpunct `8212`→`700`, out-of-range `1`→`500` (`TOFU`). Extraction required > care — injected commands raced ahead of the verbose per-word execution trace in the log, > so printed results appeared shifted relative to naive line-proximity matching; resolved > by anchoring on each command's own echo line as an ordered marker and taking the last > printed value before the next marker, not by assuming adjacency. > > Three-architecture acceptance boot clean, Stadium conservation unchanged: > amd64 (`logs/20260809-191200`), aarch64 (`logs/20260809-191237`), riscv64 > (`logs/20260809-191327`). - [x] **4.3.6c — Default system font-set capsule.** The confirmed 113-glyph v1 repertoire (95 ASCII printable + 11 Latin-1 Supplement + 7 General Punctuation, itemized in §27.6.4), each glyph a stroke-drawing word in the 4.3.6 em-square convention returning its own `em-advance`, one capsule. *Done when:* every glyph in the 113-glyph v1 repertoire is defined, lint-clean (`mkcapsule --lint`), individually verified to render a recognizable shape via screendump. *Refs:* §27.6.4. > **Done 2026-08-09.** `capsules/font.4th`, all 113 glyphs, blocks 4925-4984, 60 blocks. > > **Correction found mid-build, not designed around silently:** the first pass built every > glyph with `G-LINE` only, missing that `CIRCLE`/`ARC`/`ELLIPSE` already existed > (item 4.3.3b, `fabric.4th` 4907-4912) for exactly this. User caught it and chose to rework > everything already built, not just curves going forward. Required new em-square-aware > wrappers — `EM-R` (magnitude scaling, no `GOX`/`GOY` translation) plus `G-CIRCLE`/ > `G-ARC`/`G-ELLIPSE`, added to `fabric.4th` blocks 5000-5002 (non-contiguous with > `fabric.4th`'s own 4900-4924 range because `font.4th` had already claimed 4925+ — > BLOCK_MAP.md checked before picking 5000). Scoped to this item (4.3.6c), not a reopening > of 4.3.6, same precedent as the FORGET-hook/`stadium_owner` additions elsewhere in this > list — but 4.3.6 itself should be read as having grown these three words later. > `ARC`'s sweep direction was derived, not assumed (`ang=0`→3 o'clock, increasing angle > sweeps CCW through 12 o'clock) and confirmed by a one-shot render before any glyph used > it (top semicircle at `a0=0 a1=PI`, matched the derivation). > > **Bounded curve scope** (advisor-flagged risk: hand-tuning arc sweep angles per glyph is > unbounded, one QEMU boot per iteration). Only quarter/half/three-quarter/full sweeps of > the four Q48.16 literals (`0`, `PI/2`=102944, `PI`=205887, `3PI/2`=308831) plus full > `CIRCLE`/`ELLIPSE`, no custom-fitted angles. Converted: digit `0`, upper `O` `Q` `C` `G` > `D`, lower `o` `b` `d` `p` `q` `c` `e`, punct `%` `.` `@`. `D`'s bowl and the lowercase > `b`/`d`/`p`/`q` bowls use one `ARC` each, radius = half the bowl height, centered on the > stem's x — the arc's two endpoints land exactly on the stem ends by construction (no > seam). Everything else (`S`/`s`/`&`/digits 2-9/etc.) stays the original blocky `G-LINE` > style — recognizable, not hand-fitted, per the same advisor guidance. > > **Early-binding fix applied, not just documented:** per §27.6.5 (found during 4.3.6b), > `CASE` binds early, so `font.4th` redefines all six `DISPATCH-*` bucket words with real > `CASE` tables (replacing fabric.4th's 4.3.6b placeholders) *and* redefines > `DISPATCH-GLYPH`/`DRAW-GLYPH` themselves (blocks 4979-4984) — fabric.4th's originals were > also compiled early, against the old placeholder buckets, so redefining only the six > bucket words would not have been enough. Verified live: `DRAW-GLYPH` called directly with > codepoints 48/67/111/176 rendered the real `0`/`C`/`o`/`°` (ellipse/arc/ellipse/circle), > not `TOFU`, confirming the full pipeline binds to the new definitions. > > Verification: `mkcapsule --lint capsules/font.4th` clean. All 113 glyphs screendump- > verified in one grid render (digits+`%.@`, upper, lower, Latin-1+GenPunct rows) plus a > separate `DRAW-GLYPH`-pipeline render — arc-bowl direction, oval shapes, and dispatch > routing all confirmed correct by eye. > > Three-architecture acceptance boot clean, Stadium conservation unchanged > (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 (`logs/20260809-202214`), > aarch64 (`logs/20260809-202252`), riscv64 (`logs/20260809-202342`). - [x] **4.3.6d — Boot-time loading.** Wire `fabric.4th` and the 4.3.6c font capsule into `init.4th`'s boot chain. *Done when:* standard three-arch QEMU boot shows both capsules `EXEC`'d cleanly as part of the normal boot sequence (not manual injection), no errors, logs committed. *Refs:* §27.6. > **Done 2026-08-09.** `capsules/init.4th` block 2049, right after the existing > `S" lib.4th" EXEC`: added `S" fabric.4th" EXEC` then `S" font.4th" EXEC`. `init.4th` is > Hera's Mama `IDENTITY` capsule, so this runs automatically on every boot, not via manual > injection. Verified by booting normally (no serial injection at all) and calling > `DRAW-GLYPH` directly — codepoint 79 (`O`) rendered its real ellipse glyph, proving both > capsules were live in the dictionary from the standard boot chain alone. > > Three-architecture acceptance boot clean, Stadium conservation unchanged > (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 (`logs/20260809-202736`), > aarch64 (`logs/20260809-203103`), riscv64 (`logs/20260809-203156`). - [x] **4.3.6e — User font override.** Mechanism resolved 2026-08-09 (§27.6.5): override replaces a whole `DISPATCH-*` bucket word, not individual glyphs — `CASE`'s early binding means redefining a single glyph word (e.g. `G-A`) alone does not change what an already-compiled `DISPATCH-UPPER` calls, so the override capsule must redefine the entire bucket it wants to change. *Done when:* a second, user-supplied capsule redefines one `DISPATCH-*` bucket word (e.g. `DISPATCH-UPPER`), and after loading it, drawing a codepoint from that bucket renders the overridden glyph, not the default — verified live via screendump, both before and after the override loads. *Refs:* §27.6.5. > **Done 2026-08-09.** `capsules/user-font-demo.4th`, blocks 4200-4202. Not wired into > `init.4th` — an optional capsule loaded manually via `EXEC`, same as `doe.4th`. > > **§27.6.5 was incomplete, corrected here rather than silently followed.** The resolved > note said the override capsule must redefine the whole `DISPATCH-*` bucket word — true > but insufficient. `DISPATCH-GLYPH` and `DRAW-GLYPH` are themselves compiled early (in > `font.4th`, against `font.4th`'s own `DISPATCH-UPPER`) — the same finding already hit > once while building 4.3.6c's own dispatch wiring, applying again one layer up. Redefining > only `DISPATCH-UPPER` would not have been picked up; `user-font-demo.4th` redefines > `DISPATCH-UPPER` *and* `DISPATCH-GLYPH`/`DRAW-GLYPH` (blocks 4201-4202). Confirmed by > testing the incomplete version first — redefining `DISPATCH-UPPER` alone left `DRAW-GLYPH` > still drawing the original `G-A`, exactly as the early-binding rule predicts. > > Verified live, single boot, one screendump: codepoint 65 drawn via `DRAW-GLYPH` before > `S" user-font-demo.4th" EXEC` renders the plain default `A`; the same call after renders > `G-A-ALT` (the `A` with a diagonal cross through it), side by side in one image. All > other letters (`G-B`..`G-Z`) render unchanged, calling `font.4th`'s originals directly by > name — proving override granularity is per-bucket, not all-or-nothing across buckets. > > Three-architecture acceptance boot clean, Stadium conservation unchanged > (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 (`logs/20260809-203815`), > aarch64 (`logs/20260809-203858`), riscv64 (`logs/20260809-203950`). - [x] **4.3.6f — `TEXT` entry point.** `TEXT ( c-addr u x y size color -- )`: decode UTF-8 via 4.3.6a, dispatch each codepoint through 4.3.6b/`DRAW-GLYPH`, draw via its stroke word, scale its returned `em-advance` to pixels (`GSIZE @ EM-UNITS */`) and accumulate into the cursor X, per §27.6.6. *Done when:* a string containing ASCII, at least one Latin-1 character, and at least one General Punctuation character (e.g. an em dash) renders correctly on the CANVAS in one call, verified via screendump, with correct proportional spacing (no overlapping/misspaced glyphs, visibly different advance widths for e.g. `i` vs `M`). *Refs:* §27.6.6. > **Done 2026-08-09.** `capsules/font.4th` block 4985, using `2>R`/`2R>` to stash the > `DECODE-UTF8` remainder (`addr' u'`) off the data stack while `DRAW-GLYPH`'s five args > (codepoint already sits at the right stack depth for it) are pushed and consumed — > avoids the alternative of re-deriving the remainder pointer by hand each iteration. > Defined in `font.4th`, not `fabric.4th`, for the same reason `DISPATCH-GLYPH` had to be > redefined there in 4.3.6c: a `fabric.4th`-compiled `TEXT` would bind early to > `fabric.4th`'s own placeholder `DRAW-GLYPH`, not the real one. > > Verified live: `S" Hi 30° end—X"` (ASCII + `°` U+00B0 Latin-1 + `—` U+2014 General > Punctuation, mixed in one string) rendered via one `TEXT` call — correct glyphs, no > overlap, visibly proportional spacing (`i` narrow, `H`/`X`/`m`-width chars wider), > confirmed by screendump. > > Three-architecture acceptance boot clean, Stadium conservation unchanged > (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 (`logs/20260809-204422`), > aarch64 (`logs/20260809-204458`), riscv64 (`logs/20260809-204550`). - [x] **4.3.6g — Checkpoint: render every v1 glyph on the CANVAS.** Same posture as 4.3.4/4.3.5f — stop and review here before scoping REPL wiring (M8). *Done when:* a single screendump shows all 113 glyphs in the v1 repertoire (§27.6.4) rendered legibly and correctly positioned, on all three architectures, matching the 4.3.3b/4.3.4 completion pattern (screendump-verified, not just non-crashing). Three-arch acceptance boot clean, logs committed. *Refs:* §27.6. > **Done 2026-08-09.** Fixed the cross-arch framebuffer-size bug found while first > attempting this checkpoint (paused note, now superseded): aarch64 and riscv64 both boot > an 800×600 `ramfb` device (confirmed for riscv64 too, not assumed from aarch64's number — > checked via screendump pixel dimensions), against amd64's 1280×800 GOP framebuffer. > Rebuilt the verification grid to fit 800×600 (7 rows, `GSIZE 28`, all rows below the boot > log text), which is automatically safe on amd64's larger screen too. > > All 113 glyphs confirmed legible and correctly positioned, one screendump per > architecture, no clipping, no dispatch errors: amd64, aarch64, riscv64 all rendered from > the identical command sequence (same FORTH source, same VM, different framebuffer > geometry only). > > Three-architecture acceptance boot clean, Stadium conservation unchanged > (`resident_sum=43691 reservoir=21845 sum=65536`): amd64 (`logs/20260809-213223`), > aarch64 (`logs/20260809-213301`), riscv64 (`logs/20260809-213354`). > > Stroke font (4.3.6–4.3.6g) is now fully complete. Per this item's own posture, stop here > before scoping REPL wiring (M8) — 4.3.7 (TrueType, adjunct) is the scoped-but-not-started > next work, not M8. - [x] **4.3.7 — TTF parser core.** A C module (not FORTH — parsing and rasterization are impractical to interpret) reading a `.ttf`'s `sfnt` directory plus `head`/`maxp`/`loca`/ `glyf`/`cmap` tables, resolving a codepoint to a glyph index and its outline data. All scaled/derived values in Q48.16, not float — see §27.7. *Done when:* given an embedded test font's bytes in memory, the parser resolves at least one ASCII codepoint to the correct `glyf` table offset and reads its outline header (contour count, bounding box), verified against values independently read from the same font with a reference tool (e.g. `fonttools`/`ttx`), not just "doesn't crash." *Refs:* §27.7. > **Done 2026-08-10.** `include/starkernel/ttf.h` + `src/starkernel/hal/ttf.c`: sfnt > directory walk, `head` (unitsPerEm, indexToLocFormat)/`maxp` (numGlyphs)/`loca`/`glyf` > table location, and a format-4 `cmap` subtable selector + lookup (format 12 explicitly > deferred — not needed for the BMP-only v1 glyph repertoire). Every multi-byte read is > manually big-endian-decoded with a bounds check against the buffer length first — no > libc byteswap dependency, matches the freestanding/no-libc build (`-ffreestanding > -nostdlib -fno-builtin`, confirmed by direct single-file compile against the real > `Makefile.starkernel` amd64 `COMMON_CFLAGS`/`ARCH_CFLAGS`, zero warnings under > `-Wall -Werror -Wextra`). No Q48.16 conversion needed here — every field this item reads > is a raw on-disk integer, not a scaled/derived value; Q48.16 becomes relevant starting > 4.3.7a (outline point extraction). > > Verification tool substitution, recorded plainly: `fonttools`/`ttx` is not installed in > this environment (no network install attempted). Verified instead against a from-scratch > second implementation — a plain `struct`-module Python script > (`/tmp/.../scratchpad/ttf_ref.py`, not committed, reproducible from this note) that > shares no code with `ttf.c` — arguably a stronger independence guarantee than a > shared-library-backed tool would have given, though not what the item text named. A host > test harness, `tools/ttftest.c` (new, follows `tools/README.md`'s already-established > "host test, no QEMU needed" pattern — note `fbtest.c`, that entry's other example, turns > out not to actually exist in `tools/`; stale-doc discrepancy, reported not fixed), > compiles `ttf.c` directly and checks 7 codepoints from `fonts/JetBrainsMono-Regular.ttf` > (`A a 0 . ! @` plus space, the empty-glyph case) against the Python reference's output — > glyph index, contour count, bounding box (including two negative-`yMin` cases, `a` and > `@`, which exercise signed-field decoding), `glyf` offset, and `glyf` length all match. > `gcc -std=c99 -Wall -Wextra -Werror`, zero warnings. > > `.ttf` bytes reach the parser via a plain host `fopen`/`fread` into a `malloc` buffer in > `ttftest.c`, not yet via a capsule (that's 4.3.7b, still open — see §27.7's 2026-08-10 > note on the raw-blob-vs-hex/base64 design question). `ttf_parse()` itself is agnostic to > how its buffer arrived, so this doesn't gate 4.3.7's own "done when" clause. > > `fonts/JetBrainsMono-Regular.ttf` added (SIL OFL 1.1, confirmed by reading the font's own > embedded `name`-table license string directly, not assumed from the filename) as the v1 > test/default font, resolving §27.7's licensing-check blocker — see `fonts/README.md`. > > Not a kernel-boot change (no FORTH words, no capsule/init.4th wiring, `ttf.c` isn't > called from anywhere in the boot path yet — it's picked up by the existing > `hal/*.c` wildcard in `Makefile.starkernel` but dead code until something calls it) — no > three-arch QEMU acceptance boot applies to this item; the amd64 single-file freestanding > compile check above is what stands in for it, per this item's own "done when" clause > (which never asked for a kernel boot). - [x] **4.3.7a — Glyph outline extraction.** Simple and composite glyph outlines from `glyf` — on-curve/off-curve point lists, quadratic Bézier control points, composite glyph transforms — in Q48.16. *Done when:* outline point lists for a handful of test glyphs (including at least one composite, e.g. an accented character if the test font has one) match reference-tool output within Q48.16 rounding tolerance. *Refs:* §27.7. > **Done 2026-08-10.** `ttf_glyph_outline()` in `src/starkernel/hal/ttf.c`/`ttf.h`: flags > run-length decode, delta-decoded x/y coordinates (simple glyphs), and recursive composite > component resolution, into caller-supplied point/contour-end buffers (no allocation in > this module). Points are stored in raw font design units shifted into Q48.16 > (`q48_from_i32`, a local two's-complement left-shift — deliberately not routed through > `q48_mul`/`q48_div`, see below). > > **Known gap, reported not fixed, per this repo's rule against modifying a shared/tested > module without being asked:** `src/starkernel/math/q48_16.c`'s `q48_mul`/`q48_div` are > unsigned-only (`q48_div` saturates via an unsigned overflow check on `a`; `q48_mul` does a > plain unsigned widen-multiply) — confirmed by reading the source before writing any of > this, not assumed. A composite glyph's transform can carry a signed F2Dot14 scale/ > rotation/skew, which needs a *signed* fixed-point multiply that function doesn't provide. > Resolution taken here: `decode_composite_glyph()` applies (dx,dy) translation only (plain > `q48_add`, safe under two's-complement regardless of the unsigned typing) and explicitly > **rejects** any component with a non-identity scale/2×2 transform or point-matched > (non-xy-offset) args, returning `TTF_ERR_UNSUPPORTED` rather than silently mis-rendering > it. Checked, not assumed: every composite in `fonts/JetBrainsMono-Regular.ttf` (11 sampled > accented Latin glyphs: `ÁÉÍÓÚÑéáñüö`) uses identity-scale translation-only components, so > this doesn't block the v1 repertoire (§27.6.4) — but it's a real limitation for an > arbitrary future font, and fixing it means either giving `q48_16.c` a signed multiply > variant or doing the scale math locally the same way translation already is. Whoever picks > that up next should decide which, not silently patch it in passing. > > Verified against `/tmp/.../scratchpad/ttf_outline_ref.py` (not committed, reproducible > from this note) — a second from-scratch Python decoder sharing no code with `ttf.c`, > covering the same "no `fonttools` installed" substitution already recorded under 4.3.7. > Three glyphs checked point-for-point (coordinates, on/off-curve flags, contour-end > indices) via the extended `tools/ttftest.c`: `.` (12 points, 1 contour, simple), `A` (17 > points, 2 contours, simple), and `á` (43 points, 3 contours, a genuine 2-component > translation-only composite exercising the recursive path) — all match exactly. > `gcc -std=c99 -Wall -Wextra -Werror`, zero warnings; single-file freestanding compile > against the real amd64 `Makefile.starkernel` flags also re-checked clean. Same "not a > kernel-boot change yet" posture as 4.3.7 — no FORTH/capsule wiring exists for this module, > so no three-arch QEMU acceptance applies here either. - [x] **4.3.7b — Font data ingestion.** `.ttf` bytes encoded (hex or base64 — pick one, record why) into capsule blocks per the resolved design decision (§27.7), decoded into a `kmalloc` buffer at capsule load time. *Done when:* a font capsule loads cleanly (`mkcapsule --lint`), and the decoded in-memory bytes checksum-match the original `.ttf` file. *Refs:* §27.7. > **Done 2026-08-10, on the corrected design (§27.7's 2026-08-10 correction, not the > hex/base64 premise this item's own text was written against — see that note for the > full story of why).** `capsules/fonts/JetBrainsMono-Regular.ttf` added; `mkcapsule.c` > ingests it unmodified as capsule `fonts:JetBrainsMono-Regular.ttf` (raw bytes, no text > encoding, no tool changes needed — it already handles arbitrary files). Verified two ways: > `mkcapsule --lint capsules/` passes clean (29 `.4th` files, 0 violations — the `.ttf` > isn't `.4th` so lint correctly skips it rather than misapplying text-block rules to it); > and a byte-exact round-trip check — built `capsule_generated.c`, extracted the > `capsule_arena[]` bytes at the font's descriptor `offset`/`length` (270224 bytes) back out > with a throwaway script, `cmp`'d against the source `.ttf`: identical. > > No `kmalloc` copy, since none is needed: `ttf_load_from_capsule()` (new, in `ttf.c`/`ttf.h`, > `#ifdef __STARKERNEL__`-gated) resolves the capsule via `capsule_find_by_name()`, validates > its content hash via `capsule_validate(..., verify_hash=1)`, and points `ttf_font_t` at the > `capsule_arena` bytes directly (`capsule_get_payload()`) — zero-copy, since the arena is > already a `const` array baked into the kernel image and `ttf_parse()` only ever reads > through a `const uint8_t*`. Compiles clean (`-Wall -Werror -Wextra`) both with and without > `-D__STARKERNEL__`, against the real amd64 `Makefile.starkernel` flags including > `-include include/starforth_config.h -DPARITY_MODE=0`. Not callable from FORTH yet — no > capsule-birth/init.4th wiring exists for it; that's 4.3.7e's job once `TTF-TEXT` needs it. > Same "not a kernel-boot change" posture as 4.3.7/4.3.7a; no three-arch QEMU run applies. - [x] **4.3.7c — Rasterization.** Bézier curve flattening to line segments (reusing the existing `LINE`/Bresenham primitive where practical), then fill. Antialiasing approach is an open question — not decided here, resolve when this item is picked up. *Done when:* a single glyph outline rasterizes to a recognizable filled (or outlined, if AA is deferred) shape on the CANVAS, screendump-verified. *Refs:* §27.7. > **Done 2026-08-10.** `ttf_rasterize_glyph()` in `src/starkernel/hal/ttf.c`/`ttf.h`: > quadratic-Bezier contour flattening (fixed 8-segment subdivision per curve, same > fixed-segment-count approach as `capsules/fabric.4th`'s `CIRCLE`/`ELLIPSE`, 36 segs, and > `ARC`, 18 segs — no adaptive tessellation) followed by an even-odd scanline fill into a > caller-supplied 8-bit-per-pixel bitmap. No antialiasing, per this item's own allowance. > > **Doc error found and reported, not fixed:** §27.7 decision #1 claims 4.3.3b put > `LINE`/`CIRCLE`/`ARC`/`ELLIPSE` "in C rather than FORTH" — 4.3.3b's own completion note > says the opposite: they're FORTH words in `capsules/fabric.4th` (blocks 4903–4912), built > on the C-level `PLOT`/`TO-RASTER` primitives. C is still the right call for this item > (4.3.7's item text says so directly, independent of that reasoning), but "reusing the > existing `LINE`/Bresenham primitive" from a C module can't mean literally calling the > FORTH word — it means the same Bresenham/scanline approach at the C level, which is what > this implementation does (no FORTH call from `ttf.c`). > > **Fill rule: even-odd, not TrueType's native nonzero winding.** Simpler to implement (no > edge-direction bookkeeping) and identical to nonzero winding for the v1 glyph repertoire > (§27.6.4), whose contours are simple and properly nested (outer contour + inner counters, > e.g. `a`/`o`) — the two rules only diverge on self-intersecting outlines, which no v1 > glyph has. Not correct in general for an arbitrary font; whoever needs that generality > later should switch to nonzero winding (requires tracking edge direction, not otherwise > hard). > > **Local signed Q48.16 multiply (`q48_smul`), not the shared `q48_mul`.** Rasterization > scales outline coordinates (routinely negative — e.g. `a`'s yMin is −10) by a > non-negative scale/Bezier-blend weight, exactly the signed use case `q48_mul`/`q48_div` > (`src/starkernel/math/q48_16.c`) don't support (see 4.3.7a's completion note). Per this > repo's rule against modifying a shared/tested module without being asked, that gap is > reported there, not patched here; `q48_smul` is local to `ttf.c`, a plain > `((int64_t)a * (int64_t)b) >> 16`, safe because every value in this module (glyph > coordinates scaled to at most a few hundred pixels) stays far inside `int64_t` range. > Scanline edge-intersection division is done as plain `int64_t` arithmetic directly > (`x0 + dx*t_num/dy`), not through any Q48.16 divide helper — C's native signed integer > division already handles the sign correctly there, so no wrapper was needed for that part. > > **Verification.** Host-side (`tools/ttftest.c`, extended): `test_rasterize()` rasterizes > `A`/`.`/`a` at 28px into a small bitmap and prints an ASCII-art dump plus structural > checks (non-empty, not implausibly full, background corner untouched). All three glyphs > render as visually correct, recognizable letterforms; all checks pass. No independent > reference rasterizer exists to diff pixel-for-pixel against (unlike 4.3.7/4.3.7a, which > had a from-scratch Python decoder) — the item's own acceptance bar is "recognizable," not > pixel-exact, so this is judged sufficient. > > **Live screendump, amd64, per this item's explicit requirement (the only 4.3.7–4.3.7b > items so far to need one).** A throwaway registered word, `TTF-PROBE` (added to > `src/word_source/framebuffer_words.c`, `__STARKERNEL__`-gated), loaded the font capsule, > rasterized `'A'` at 48px, and blitted it via `fb_put_pixel()` at screen position > (100,100). Boot: custom one-off script (not a repo file — mirrors > `scripts/qemu_screenshot.sh`'s monitor-socket + `socat` + HMP `screendump` pattern, plus > a serial-socket command injection channel that script doesn't have, same injection > technique used throughout 4.3.x) built the amd64 ISO, booted to `[Hera] ok>`, injected > `TTF-PROBE` over the serial socket, confirmed `TTF-PROBE: rasterized 'A' at (100,100)` in > the serial log, then issued `screendump` over the monitor socket. Result: a clearly > legible white `'A'` at the expected screen position, committed as > `fb/amd64/ttf-rasterize-glyph-A-20260810-221922.png`. Probe reverted immediately after > (`framebuffer_words.c` back to its pre-probe content, confirmed via `git diff`) — per > feedback-revert-probes-after-capture, only the permanent rasterizer in `ttf.c`/`ttf.h` > survives. > > **Compile-checked on all three architectures** (`make -f Makefile.starkernel ARCH= > clean` then plain build, no `qemu`), zero warnings from `ttf.c` on any — this item's own > "done when" only requires the amd64 screendump, not a three-arch boot (that's 4.3.7f's > job), but `ttf.c` builds into all three via the `hal/*.c` wildcard so a portability > compile check costs little and catches real bugs early. - [x] **4.3.7d — Glyph raster cache.** Rasterizing on every draw call is too slow for repeated text; cache rasterized bitmaps keyed by (font, codepoint, size). *Done when:* drawing the same codepoint/size twice measurably hits the cache on the second call (e.g. a counter or timing difference), verified live, not just "code that should cache." *Refs:* §27.7. > **Done 2026-08-10.** `ttf_raster_cache_get()`/`ttf_raster_cache_init()` in > `src/starkernel/hal/ttf.c`/`ttf.h`: a fixed, caller-owned slot array (no allocation, same > convention as the rest of this module), linear-scan lookup keyed by (font pointer, > codepoint, size_px), round-robin eviction once every slot is full. Each slot is a fixed > `TTF_CACHE_BITMAP_DIM` (80×80) square, rasterized at a fixed origin > (`TTF_CACHE_MARGIN`, `size_px + TTF_CACHE_MARGIN`) regardless of the glyph's own bounding > box — a caller doing real text layout (4.3.7e) needs to know this fixed convention, not > assume the bitmap is tightly cropped to the glyph. > > **Verified live, not just "code that should cache"** (this item's own bar): extended > `tools/ttftest.c`'s `test_raster_cache()` calls `ttf_raster_cache_get()` twice for the > identical (font, `'A'`, 24px) key — first call returns `was_hit=0` (rasterized), second > returns `was_hit=1` (cache hit), and the slot's own `hits` counter reads exactly 1 > afterward, checked programmatically, not just printed. A third call for a different > codepoint (`'a'`) at the same size misses again, proving the key actually discriminates > rather than the cache just always reporting "hit". Wall-clock `clock()` timing is also > printed as corroborating evidence (miss 0.040ms vs. hit 0.001ms on this run) but is > explicitly labeled informational-only in the test's own output, since host `clock()` > resolution is coarse and this repo doesn't treat unverified timing claims as proof on > their own — the hit counter is the load-bearing check. > > No kernel-boot/screendump verification needed — this item's own "done when" only asks > for a measurable hit, which the host test above demonstrates directly; unlike 4.3.7c, > nothing here is CANVAS-visual. Compile-checked clean (`-Wall -Werror -Wextra`) on all > three architectures, with and without `-D__STARKERNEL__`. - [x] **4.3.7e — `TTF-TEXT` entry point.** `TTF-TEXT ( c-addr u x y size color -- )`, analogous to 4.3.6f's `TEXT` but TrueType-backed — becomes the primary text-rendering path per the resolved relationship to the stroke font (§27.7); `TEXT`/the stroke system remain available, not deprecated. *Done when:* a UTF-8 string renders correctly via `TTF-TEXT` in one call, screendump-verified, proportional spacing correct. *Refs:* §27.7. > **Done 2026-08-10.** New `src/word_source/ttf_words.c`/`ttf_words.h`, registered from > `word_registry.c` as Module 30. `TTF-TEXT` lazily loads the v1 default font capsule > (`fonts:JetBrainsMono-Regular.ttf`) and its raster cache once on first use, decodes the > UTF-8 string byte-by-byte (a C reimplementation mirroring `capsules/fabric.4th`'s > `DECODE-UTF8` exactly -- same lead-byte-length table, same U+FFFD fallback), looks up > each codepoint's bitmap via `ttf_raster_cache_get()` (4.3.7d), blits it via > `fb_put_pixel()`, and advances the pen by the glyph's real `hmtx` advance width (new -- > see below) scaled to pixels via the shared `q48_mul`/`q48_div` (safe here since advance > widths and scale are always non-negative, unlike the rasterizer's signed cases). > > **Necessary plumbing added, not scope creep beyond this item's own "done when":** > `ttf_parse()` now also locates `hhea` (for `numberOfHMetrics`) and `hmtx`, and > `ttf_glyph_advance_width()` reads a glyph's advance width from it. Without this, > "proportional spacing correct" (this item's own acceptance clause) would be unmet -- > there is no advance-width data anywhere else in the parser. Verified in > `tools/ttftest.c`: `A`/`a`/`0`/space all read `advance_width=600`, correctly uniform > since `JetBrainsMono-Regular.ttf` is monospace (a real structural property to check > against, not an arbitrary assumption). > > **Coordinate convention, recorded explicitly, not conflated with the stroke font's:** > `TTF-TEXT`'s `(x,y)` is raster pixel space (top-left origin, Y-down) -- the same space > `PLOT`/`fb_put_pixel()` use -- NOT `capsules/fabric.4th`'s Cartesian Y-up space that the > stroke font's `TEXT` (4.3.6f) uses via `CART-PLOT`. These are two deliberately different > coordinate systems on two separate text paths; a caller mixing them up would get a > vertically-flipped `y`. `(x,y)` is the first glyph's baseline-left origin. > > **Verified live, amd64, screendump.** Boot: same one-off script pattern as 4.3.7c > (monitor socket + `socat` + HMP `screendump`, plus serial-socket command injection) -- > booted to `[Hera] ok>`, injected `S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT` over the > serial socket, confirmed no error in the serial log (next prompt was a clean `ok>`), then > captured a screendump. Result: `"Hi 4.3.7e!"` renders legibly at the expected position, > mixed case + digits + punctuation all correct, glyphs evenly spaced left to right with no > overlap -- `fb/amd64/ttf-text-hi437e-20260810-230926.png` (not committed; `fb/` is > gitignored, matching every other screendump referenced in this document). Unlike > 4.3.7c's `TTF-PROBE`, `TTF-TEXT` is the item's own permanent deliverable, not a > throwaway -- nothing to revert. > > Compile-checked clean (`-Wall -Werror -Wextra`) on all three architectures. - [x] **4.3.7f — Checkpoint: TTF rendering, all three architectures.** Same posture as 4.3.6g. *Done when:* a single screendump per architecture shows a representative sample string rendered correctly via `TTF-TEXT`, three-arch acceptance boot clean, logs committed. *Refs:* §27.7. > **Done 2026-08-10/11.** No code changes -- `ttf.c`/`ttf.h`/`ttf_words.c` are unchanged > since 4.3.7e; this is verification only, same posture as 4.3.6g. > > **Three-arch acceptance boot clean**, standard `make -f Makefile.starkernel ARCH= > clean qemu`, run in the required order, one at a time: `logs/20260810-231553/amd64/`, > `logs/20260810-231640/aarch64/`, `logs/20260810-231739/riscv64/` -- all three reached > `[Hera] ok>` cleanly, logs committed. > > **One screendump per architecture, identical `TTF-TEXT` command sequence** > (`S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT`, the same string already used for 4.3.7e's > amd64 verification, reused here as the "representative sample string" this item asks > for), via the same monitor-socket + serial-injection one-off script pattern as 4.3.7c/e. > `(200,200)` at 28px was already safely inside the smaller 800×600 ramfb bound found by > 4.3.6g (aarch64/riscv64 ramfb vs. amd64's 1280×800 GOP) -- confirmed, not re-derived, > since `TTF-TEXT`'s raster-space coordinates don't change with screen size the way > 4.3.6g's grid did. All three screendumps show `"Hi 4.3.7e!"` rendered legibly, correct > mixed-case/digit/punctuation glyphs, identical spacing (same monospace 600-unit advance > width on every architecture, since it's the same font capsule and same C code): > `fb/amd64/ttf-text-hi437e-20260810-230926.png` (already captured for 4.3.7e, reused > rather than re-captured), `fb/aarch64/ttf-text-hi437e-20260811-004152.png`, > `fb/riscv64/ttf-text-hi437e-20260811-004313.png` (not committed; `fb/` is gitignored, > matching every other screendump referenced in this document, including 4.3.6g's). > > TrueType rendering (4.3.7-4.3.7f) is now complete, adjunct to the stroke font per §27.7's > decision #4 -- both text paths coexist. Per this item's own posture (matching 4.3.6g), > stopping here before scoping REPL wiring (M8). > > **M8 scoped 2026-08-11 in `docs/lithosananke/ROADMAP.md`, not here** -- per §27.5, M8 is > tracked outside Stadium, so its design decisions live in that roadmap's own M8 section > rather than as new 4.3.x items. Key finding from that scoping pass, worth recording in > this document too since it bears on §27.6's CANVAS/REPL-strip framing: the REPL's > on-screen text already renders via the existing VT100 console > (`console_fb_init()`/`vt100_init()`, baked-in `font_8x16.c`), independent of both the > stroke font and TrueType work above -- neither 4.3.6-4.3.6g nor 4.3.7-4.3.7f was ever a > prerequisite for the REPL strip itself. What's still open for M8 is purely input-side: > `KEY-EVENT` (4.3.5f) isn't yet wired into the REPL's character source > (`console_getc()`), and `KEY`/`?TERMINAL` remain the dead stubs already noted above. ⋯ *(4.3.x is open-ended — more items get appended here as framebuffer/keyboard Console work* *is scoped item by item, developed on the fly per §25.0. 4.4 below is a new, independent* *section for the REPL itself — separate because the REPL is independent of the screen-output* *and keyboard work above. Artemis's item (renumbered from 4.4 to 4.5, then to 4.6,* *2026-08-11) is unaffected by anything added above this marker.)* - [x] **4.4 — Design-only: lock the prompt format and color values.** No code. Write down the exact target string — `[VM name] ok>` for now, `(user)` added later per 4.4s — and the exact color values for the bracketed VM name and for `ok>` against [`repl-mockup.png`](repl-mockup.png). Captain Bob's spoken values (orange, cyan) were both qualified as tentative/uncertain — this item is where they become specific RGB or ANSI SGR index values, or are confirmed as still-approximate and revisited later. *Done when:* a literal prompt string and two specific color values are written into this document, referencing the mockup. *Refs:* §25.5, §27.8. > **DONE 2026-08-11.** Format: `[VM name] ok>` — e.g. `[Hera] ok>`, `[Hermes] ok>`. The > entire bracketed substring (brackets included) renders in one color; `ok>` (including its > trailing space) renders in a second, distinct color. Values chosen with Captain Bob, > checked against what already exists in the codebase rather than invented cold: > - **`ok>`: bright cyan, `0x55FFFF`.** Reuses `FB_ANSI_PALETTE[14]` > (`framebuffer.c:30`) as-is — no new constant, stays consistent with the ANSI palette the > rest of the console already uses. > - **`[VM name]`: standard web orange, `0xFFA500`.** No orange exists anywhere in the > classic 16-color ANSI palette (checked — `FB_ANSI_PALETTE` has none), so this is a fresh > literal value, not a reused constant. `TTF-TEXT`'s `color` argument is a raw RGB value > (not an ANSI palette index), so this is not a constraint — any RGB is valid. > These are literal `FB_RGB()`-equivalent values for 4.4a/4.4h to consume directly; no > implementation performed by this item, per its own "no code" scope. - [x] **4.4a — Build `[VM name]` bracket text in the prompt, monochrome.** Replace the two existing, mutually inconsistent VM-identity conventions — `console.c`'s per-line `[Name]` prefix (`console_set_vm_name()`/`g_active_vm_name`, unaffected/out of scope for non-prompt lines) and `repl.c`'s prompt-suffix `)ok>` (`sk_repl_run()`/`sk_repl_step()`) — with `[VM name] ok>`, no color yet (4.4h). VM attach itself needs no new work: `USE` (`mama_forth_words.c:1108`) already sets both `g_repl_active_vm` and `console_set_vm_name()` — this item is a *display* change only. *Done when:* all three architectures boot to a bracketed `[Hera] ok>` prompt (replacing today's separate `[Hera]`-per-line-prefix + bare `ok>`); `USE`-driven attach to another VM shows `[Hermes] ok>` (etc.), replacing today's `Hermes)ok>` suffix; the serial-injection acceptance harness (greps for `ok>`) still matches; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **DONE 2026-08-11.** Caught a bug in the plan before writing it: `console.c`'s > `console_putc()` already fires the `[VMName] ` prefix at line-start whenever > `g_active_vm_name` is set — including on the prompt itself. Printing a second, manually > built bracket (the original plan) would have produced `[Hera] [Hera] ok> `. Fixed by *not* > building a bracket at all — `repl.c`'s `sk_repl_step()`/`sk_repl_run()` now print only > `"ok> "`, letting the existing per-line prefix supply `[VMName] ` for free. This also > explains, and organically fixes, the old non-Hera double-display bug noted in §27.8: the > previous `)ok>` suffix was *always* being printed after that same automatic prefix, > so a Hermes-attached prompt actually read `[Hermes] Hermes)ok> ` (name shown twice) before > this change. The `is_hera` branch and its `emergency_console` assignment are otherwise > untouched — this is a display-only change, the ACL bypass semantics are identical to > before. > > **Known, intentional gap until 4.4s:** the old prompt distinguished `zuse)ok>` from bare > `ok>` textually. That distinction is not yet visible in the new unified prompt — it moves > to the `(user)` segment, which is explicitly scoped-but-blocked at 4.4s. The underlying > `zuse_session`/`emergency_console` mechanics are unchanged; only the on-screen indicator is > temporarily gone. > > **Regression: clean.** All three architectures boot to `[Hera] ok>`, verified live: > amd64 (`logs/20260811-073408/`), aarch64 (`logs/20260811-073448/`), riscv64 > (`logs/20260811-073542/`). > > **Caveat found afterward, not fixed here:** that verification is serial-log-only — > `[VMName]` never actually reaches the framebuffer (`console.c`'s `emit_prefix()` is > serial-only by construction). Given its own item, 4.4d, rather than silently rolled into > this one. - [x] **4.4b — Resolve: does prompt color depend on 4.4j?** Investigation, not implementation. Read the REPL's actual output path (`console_puts()` → ... → `vt100_putc()` → `draw_cursor_glyph()`, all currently `font_8x16.c`) to determine whether a single hardcoded-color prompt segment can call `TTF-TEXT` directly, independent of the general ANSI/SGR retarget (4.4j), or whether all REPL output — prompt included — shares one rendering pipeline that requires 4.4j to land first before *any* TTF output reaches the screen. *Done when:* the dependency question is answered in writing here, based on reading the actual call chain, not assumed. *Refs:* §27.8. > **DONE 2026-08-11 — answer: no dependency on 4.4j.** `draw_cursor_glyph()` > (`vt100.c:125`) already calls `fb_draw_glyph(px, py, ch, f, b)` with `f`/`b` taken from > `g_vt.fg`/`g_vt.bg` — color support is already wired all the way through the *existing* > `font_8x16.c` pipeline, completely independent of which font renders the glyph shape. > `apply_sgr()` (`vt100.c:337-348`) already parses true 24-bit-color SGR: > `ESC[38;2;R;G;Bm` sets foreground RGB directly (not limited to the 16/256-color palette), > `ESC[39m` resets to default. So 4.4h does not need to wait on 4.4j/4.4i at all — it can > send `ESC[38;2;255;165;0m` (4.4's locked orange) before the bracket, `ESC[39m` after, and > `ESC[38;2;85;255;255m` (4.4's locked cyan) before `ok> `, all through `console_puts()` > exactly as it works today. TTF (4.4i/4.4j) changes *what draws the glyph*, not *whether > color reaches the screen* — those are orthogonal, confirmed by reading the call chain > rather than assumed. - [x] **4.4c — Wire `console_fb_init()` into the boot path.** Found while verifying 4.4d (below) by screendump — not a bug in 4.4a/4.4b, a pre-existing, deliberate gap. `kernel_main.c:830-834` has an explicit comment: *"console.c / vt100.c are superseded by the Console drawing-fabric redesign (FABRIC.md §27) and are deliberately not invoked here... `fb_init()` wires the raw GOP framebuffer directly... nothing else touches it."* Only raw `fb_init()` runs; `console_fb_init()` (which calls `vt100_init()`) is never called anywhere in the boot sequence. Consequence, confirmed by screendump: `vt100_putc()` no-ops on every call (`if (!g_vt.initialized) return;`), so **no console output has ever reached the framebuffer** — not REPL text, not POST/boot logs, nothing except the framebuffer driver's own corner self-test blocks (`fb_draw_orientation_test()`) and whatever TTF probes have drawn directly. ROADMAP.md's now-obsolete M8 section claimed this path was "already live" — that was never actually screendump-verified and turned out to be wrong. **End goal, stated by Captain Bob 2026-08-11:** the framebuffer console and the serial console must be functional **identically** — every character reaching one reaches the other. `console_putc()` already implements exactly that split for ordinary characters (`raw_putc()` for serial, `vt100_putc()` for framebuffer, unconditionally paired); the only thing missing is turning the framebuffer half on at all. **Fix:** call `console_fb_init(&boot_info->framebuffer, fb_fmt)` in `kernel_main.c`, replacing or immediately following the existing raw `fb_init()` call, so `vt100_init()` actually runs. *Done when:* three-arch screendump shows live boot/POST/REPL text actually rendered on the framebuffer (not just the corner test blocks) — matching what the serial log already shows; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** amd64 screendump: > [evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png](evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png) > shows real boot/REPL text on the framebuffer for the first time (not just the corner > blocks) — the fix's core mechanism works, confirmed further legible by the 4.4f fix > (see below). Three-arch serial boot clean (`logs/20260811-084941/amd64/`, > `logs/20260811-085022/aarch64/`, `logs/20260811-085119/riscv64/`, all reaching > `[Hera] ok>`). Screendump verification itself remains amd64-only — no aarch64/riscv64 > screendump tooling exists yet (Captain Bob's call 2026-08-11: acceptable gap, not a > blocker for this checkbox; building that tooling is separate future work if wanted). > Verifying this screendump surfaced three further bugs, tracked as 4.4e/4.4f/4.4g below. - [x] **4.4d — Fix: `[VMName]` prefix never reaches the framebuffer.** Depends on 4.4c (the framebuffer console must be live before this matters). Found while scoping 4.4h, given its own item per §25.0 rule 3 rather than folded in silently. `console.c`'s `emit_prefix()` (called from `console_putc()` at line-start when `g_active_vm_name` is set) writes its `[VMName] ` characters directly via `raw_putc()` — serial only, per its own comment ("no recursion into `console_putc`"). It never calls `vt100_putc()`, so the bracketed VM name has **never appeared on the actual screen**, only in the serial log — including in 4.4a's own verification, which was serial-log-only (satisfied that item's literal *Done when*, but not the visual intent behind this whole design conversation). **Fix:** route `emit_prefix()`'s characters through `vt100_putc()` as well when `fb_is_available()`, mirroring `console_putc()`'s existing serial/framebuffer split for ordinary characters — the same pattern already used there, not a new mechanism. *Done when:* three-arch screendump shows `[Hera] ok>` (or `[Hermes] ok>` etc.) actually rendered on the framebuffer, not just present in the serial log; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** Same evidence and three-arch serial-boot basis as 4.4c: > [evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png](evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png) > shows `[Hera] ok>` genuinely on screen, not just in the serial log. - [x] **4.4e — Fix: `console_putc()` forwards `\n` to `vt100_putc()` without `\r`, corrupting line starts.** Found while verifying 4.4c/4.4d by screendump — code is pre-existing, not introduced by either item, just invisible until 4.4c turned the framebuffer console on. `console.c`'s `raw_putc()` auto-injects `\r` before `\n` for the serial path (line ~176-186); `console_putc()` forwards the *same* raw `\n` to `vt100_putc()` with no accompanying `\r` (console.c:232-233). `vt100_putc()`'s `\n` handler (vt100.c:464-469) only increments the row, never resets the column. Consequence, confirmed by screendump: every line after the first starts at whatever column the previous line happened to end on, not column 0 — text scatters diagonally across the screen instead of forming a left-aligned column. **Fix:** emit `\r` to `vt100_putc()` alongside `\n` wherever `console_putc()` mirrors to the framebuffer, matching what `raw_putc()` already does for serial. *Done when:* three-arch screendump shows every REPL/boot text line starting at column 0; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** amd64 screendump: > [evidence/amd64/qemu-screenshot-20260811-082822-post-4.4e-fix.png](evidence/amd64/qemu-screenshot-20260811-082822-post-4.4e-fix.png) > shows every line starting at column 0, compared against the pre-fix screendump above > where lines started at scattered x-positions. 4.4f (glyph flip) and 4.4g (missing lines) > are both still present, unchanged, in this same screendump — confirming they are > independent bugs, not caused by the `\r`/`\n` issue. Three-arch serial boot clean, same > basis and same logs as 4.4c. - [x] **4.4f — Fix: character glyphs rendered vertically flipped on the framebuffer.** Found while verifying 4.4c/4.4d by screendump. `[Hera] ok>` was legible in content and position but every glyph appeared upside-down under 6× zoom. **Root cause:** `font_8x16_data` (font_8x16.c) stores each glyph's 16 scanline bytes bottom-to-top, not top-to-bottom, confirmed by hand-decoding two glyphs' raw bytes — `'A'` (0x41) reads as a shape converging to a point at the *bottom* unless the row order is reversed, at which point it becomes the textbook capital A (narrow apex, crossbar, splayed feet); `'T'` (0x54) reads as a bar *under* a stem unless reversed. `fb_draw_glyph()` (framebuffer.c:186-192) mapped glyph data row 0 to the top pixel row (`py + row*scale`), assuming top-to-bottom storage — wrong for this table. **Fix:** flip the row-to-pixel mapping in `fb_draw_glyph()` to `py + (15 - row) * scale`, leaving the 4096-byte font table itself untouched. *Done when:* root cause identified and three-arch screendump shows glyphs in correct orientation; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** > [evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png](evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png) > — `[Hera] Heartbeat: ...`, `[Hera] LithosAnanke v1.5.4`, `[Hera] ok>` all legible in > correct orientation, compared against the upside-down glyphs in the pre-fix screendump > linked from 4.4d/4.4e above. Three-arch serial boot clean, same basis as 4.4c. > Screendump verification remains amd64-only, same accepted gap as 4.4c/4.4d/4.4e. - [x] **4.4g — Design decision needed: `console_fb_init()` runs after capsule birth, not before, so almost all boot output is framebuffer-invisible by construction.** Found while verifying 4.4c/4.4d by screendump — initially logged as an unexplained rendering anomaly (hundreds of lines of `log_message()`-driven HADES/ECW/Stadium/self-test output missing from a screen with room for 50 character rows), but root-caused 2026-08-11, and it isn't a rendering bug at all. **Root cause:** `capsule_birth_mama()` (kernel_main.c:622) runs `init.4th`, which births the whole Tripod fleet (Hermes ×2, Artemis) and their self-tests — this single C call is where essentially all of `log_message()`'s HADES/ECW trace and the Stadium/self-test output originates, synchronously, inside FORTH interpretation. `console_fb_init()` doesn't run until kernel_main.c:852, well after that call returns. So that entire block of output is serial-only by construction, the same structural reason M1–M6 output is serial-only — just far larger in extent than originally scoped (this item's initial framing wrongly assumed "boot output" meant only the pre-VM M1–M6 messages). Only the small tail after line 852 — heartbeat summary, version banner, REPL banner, `ok>` — ever reaches the framebuffer, and that tail is legible and correctly scrolled once 4.4c/4.4e/4.4f are applied; there is no scroll or CSI-parser defect. `fb_scroll_rows()` (framebuffer.c:257) was read during the original investigation and is fine. **Open question, not decided:** does 4.4c's stated goal ("every character reaching [the serial console] reaches the [framebuffer] other") require moving `console_fb_init()` earlier — e.g. before `capsule_birth_mama()` — so the fleet-birth/self-test transcript becomes framebuffer-visible too? That's a boot-sequencing change with its own considerations (framebuffer must still be probed/available at that point; scroll volume during birth would be substantial), not something to decide unprompted. *Done when:* Captain Bob decides whether to reorder `console_fb_init()` relative to capsule birth; if yes, implement and three-arch-verify; if no, close this item as "working as designed" with this root-cause note as the record. *Refs:* §27.8. > **Evidence, amd64, 2026-08-11:** > [evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png](evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png) > — the visible tail (Heartbeat/version/REPL banner/`ok>`) is exactly, and only, the > `console_putc()` calls issued after kernel_main.c:852; everything above that line in the > serial log predates the framebuffer console's existence. > **Decided, 2026-08-11 — yes, reorder.** Captain Bob's reasoning: the serial log already > captures the full transcript losslessly, so this isn't about not losing data; it's about > that same transcript also being visible on a real screen. Weighed against 4.5f's measured > cost (~12x more boot-time heartbeat ticks, roughly 2700–5300 vs ~200 depending on > architecture) — confirmed one-shot, paid only during the fleet-birth/self-test sequence > at boot, never repeated during REPL/runtime — and judged worth it ("that's nothing"). > `console_fb_init()`'s call site moved permanently in `kernel_main.c`, from after > `capsule_birth_mama()` to just before it. Three-arch verified: amd64 > (`logs/20260811-171007/amd64/`), aarch64 (`logs/20260811-171118/aarch64/`), riscv64 > (`logs/20260811-171322/riscv64/`) — all reach `ok>`, POST `Failed: 0`, identical > dict-hashes across all three. Screendump on the landed build: > [evidence/amd64/qemu-screenshot-20260811-171612-4.4g-landed.png](evidence/amd64/qemu-screenshot-20260811-171612-4.4g-landed.png) > — framebuffer now shows the full HADES/ECW/Stadium/self-test transcript, not just the tail. - [x] **4.4h — Apply color to the prompt.** Depends on 4.4d (coloring a bracket that isn't drawn on screen accomplishes nothing). Per 4.4b's finding: either an independent `TTF-TEXT` call with the color from 4.4, or gated on 4.4j completing first. *Done when:* three-arch screendump shows `[VM name]` and `ok>` in the colors locked at 4.4; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** Per 4.4b's finding, no dependency on 4.4j/TTF-TEXT was needed — sent > real SGR escape sequences through the existing `font_8x16.c`/`vt100.c` pipeline, which > already parses 24-bit color (`apply_sgr()`, `vt100.c:309-375`). > `console.c`'s `emit_prefix()` now wraps `[VMName]` (brackets included) in > `ESC[38;2;255;165;0m` ... `ESC[39m` (4.4's locked orange). `repl.c`'s two `"ok> "` call > sites (`sk_repl_step()`, `sk_repl_run()`) now send `ESC[38;2;85;255;255mok> ESC[39m` > (4.4's locked cyan) via a shared `SK_PROMPT_TEXT` macro. Both send the same escape bytes to > serial (`raw_putc()`) and framebuffer (`vt100_putc()`), matching the existing dual-path > pattern rather than adding a new mechanism — an ANSI-aware serial terminal renders the same > colors as the framebuffer, per 4.4c's "identical on both" goal. > > Three-arch verified: amd64 (`logs/20260811-172845/amd64/`), aarch64 > (`logs/20260811-173135/aarch64/`), riscv64 (`logs/20260811-173331/riscv64/`) — all reach > `ok>`, POST `Failed: 0`, identical dict-hashes. Color applies correctly to any VM name, not > just Hera — confirmed via the `[Hermes]`-prefixed `PARITY:BIRTH` line in all three logs. > Screendump verification remains amd64-only, same accepted gap as 4.4c (no aarch64/riscv64 > screendump tooling exists): > [evidence/amd64/qemu-screenshot-20260811-173006-4.4h-colored-prompt.png](evidence/amd64/qemu-screenshot-20260811-173006-4.4h-colored-prompt.png) > — `[Hera]` renders orange, `ok>` renders cyan, confirmed by direct visual inspection. - [x] **4.4i — Verify the VT100 CSI/SGR parser's glyph-draw call site is cleanly separable.** Verification only, no code change. `vt100.c`'s CSI/SGR state machine (escape parsing, cursor tracking, 16/256-color SGR) calls `draw_cursor_glyph()` from a single site in `put_char()` — confirm nothing else in the parser depends on `font_8x16.c`-specific assumptions. **Specifically check:** the cursor/cell-grid model (`g_vt.cols`/`g_vt.rows`/`cell_h()`) assumes fixed-width character cells; `TTF-TEXT` uses real per-glyph `hmtx` advance widths (proportional in general). The chosen v1 font, `JetBrainsMono-Regular.ttf` (§27.7), is nominally monospace, so advance widths *should* be uniform and tile into the existing fixed-cell grid — but this item confirms that by reading the font's actual `hmtx` table for the glyphs in use, not by assuming "monospace" in the filename guarantees it. *Done when:* the call-site separation is confirmed clean, and the advance-width/cell-grid compatibility question is answered from the actual font data, in writing here. *Refs:* §27.8. > **Done 2026-08-11 — NOT cleanly separable as originally framed; two real gaps found and > recorded here for 4.4j to address, not silently worked around.** > > **1. `draw_cursor_glyph()` itself is single-call-site, as claimed** — called only from > `put_char()` (`vt100.c:442`), which is itself called from a single site in the main > character-dispatch loop (`vt100.c:486`). Retargeting that one call is clean. > > **2. But there's a second, independent `fb_draw_glyph()` call site the item's framing > missed:** `erase_line_range()` (`vt100.c:235-241`), used by both `erase_display()` and > `erase_line()` for partial-line/partial-screen erase, draws a blank space glyph > (`fb_draw_glyph(px, py, 0x20, fg, bg)`) per cell rather than filling a pixel rectangle. > Full-screen erase (`erase_display(2)`, `vt100.c:261`) uses `fb_fill_rect()` instead — pixel- > based, no font dependency. So today's erase behavior is already inconsistent between modes, > and 4.4j must retarget *this* call site too, not just `draw_cursor_glyph()`'s. > > **3. The cell-grid model itself is hardcoded to `font_8x16`'s fixed metrics, not derived > from a generic "current font" abstraction:** `fb_cell_w()`/`fb_cell_h()` > (`framebuffer.c:129-130`) return `8u * g_fb.scale` / `16u * g_fb.scale` literally — not > read from font data, just baked-in constants matching `font_8x16.c`'s glyph size. `vt100.c` > builds `g_vt.cols`/`g_vt.rows` and all cursor pixel math (`px_of()`/`py_of()`) on top of > these. Additionally, `UNDERLINE_ROW` (`vt100.c:49`, value 14) is explicitly commented > "row 14 of 16" — hardcodes a position within `font_8x16`'s 16-scanline glyph grid. Neither > of these is a call-site problem exactly (nothing here calls `font_8x16.c` functions > directly), but both are real assumptions 4.4j needs to replace, not just the two draw > calls. > > **4. Font data confirmed via `hmtx` table (not assumed from the filename):** > `JetBrainsMono-Regular.ttf` — read directly with a minimal `sfnt`/`cmap`-format-4/`hmtx` > parser (no `fontTools` available in this environment). `unitsPerEm=1000`, > `numberOfHMetrics=1717`. All 95 printable ASCII glyphs (`0x20`–`0x7E`) resolve via `cmap` > and share **exactly one** advance width: `600` font units, no exceptions. Genuinely > monospace for the glyph set actually in use, confirmed from the font's own data — at any > single chosen point size, `TTF-TEXT` advances will tile uniformly, compatible with the > existing fixed-cell cursor model in principle (once that model no longer hardcodes > `font_8x16`'s specific 8×16 dimensions per point 3 above). > > **Net effect on 4.4j's scope:** three things to retarget/generalize, not one — > `draw_cursor_glyph()`'s call, `erase_line_range()`'s call, and the `fb_cell_w()`/ > `fb_cell_h()`/`UNDERLINE_ROW` metrics that currently assume `font_8x16` specifically. - [x] **4.4j — Retarget the glyph-draw call site from `font_8x16.c` to `TTF-TEXT`.** Depends on 4.4i. **Boundary, corrected 2026-08-11:** `font_8x16.c`/VT100 keeps rendering everything through and including **POST** (the parity/dictionary-hash self-test) — not just the pre-bootstrap M1–M6 messages originally scoped here. POST runs *after* the capsule/VM system is up (it hashes the loaded Mama capsule dictionary, per CLAUDE.md), so "before the capsule bootstrap" was the wrong boundary; "through POST" is later and is what Captain Bob confirmed. `TTF-TEXT` takes over only once POST completes and the interactive REPL itself starts — per Captain Bob 2026-08-11, TrueType is the primary path for anything a user is actively working with; the stroke font (`TEXT`) is reserved for later retro/game-styled work, not this item. *Done when:* REPL output and CANVAS scroll-box text render via `TTF-TEXT`; all boot-time and POST messages are unaffected (still `font_8x16.c`/VT100); three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Correction, recorded here rather than silently repeated:** §27.7 decision #4 > (2026-08-09) and Captain Bob's 2026-08-11 restatement both list "early boot" as a > stroke-font use case. That's imprecise — the stroke font is capsule-based > (`fabric.4th`) and is exactly as unavailable before the VM/capsule bootstrap as > TrueType is, per §27.6's own reasoning ("font_8x16.c stays, structurally, not by > preference"). The actual early-boot/POST fallback is `font_8x16.c` specifically, not "the > stroke font" generically. > **Done 2026-08-11.** `TTF-TEXT` (the FORTH word, `word_source/ttf_words.c`) turned out not > to be directly callable from `vt100.c` — it takes VM-stack arguments and a > string+advance-loop, a different call shape than a one-glyph-at-a-time cell draw. > `hal/ttf.c`'s primitives underneath it (`ttf_load_from_capsule`, `ttf_raster_cache_get`, > etc.) are VM-independent, though, exactly like `font_8x16.c`'s own C functions — so > "retarget to TTF-TEXT" means "retarget to the same rasterizer TTF-TEXT itself uses", > implemented as a native C helper in `vt100.c`, not a call into the FORTH word. Recording > this now so it isn't read later as scope drift. > > **Boundary implemented as a runtime switch, not a compile-time swap** (this item's own > done-when requires boot/POST to stay `font_8x16.c` while REPL output uses TTF — both must > coexist in the same binary). New `vt100_enable_ttf()` (`vt100.c`, wrapped by > `console_fb_enable_ttf()` in `console.c`) flips a mode flag; `draw_cursor_glyph()` and > `erase_line_range()` branch on it. Called exactly once, at the top of `sk_repl()` > (`repl.c`) — the actual start of "the interactive REPL itself", after boot/POST have both > already run under `font_8x16.c`. Lazily loads `fonts:JetBrainsMono-Regular.ttf` from its > capsule and `kmalloc`s a 96-slot raster cache (up from `ttf_words.c`'s 32 — sized to cover > all 95 printable ASCII with no eviction thrash, since a terminal cycles through that whole > set constantly; ~614 KB, checked against the 2 GiB kmalloc heap default before choosing it, > trivial by comparison) on first call only. > > **Cell geometry changes at the switch, deliberately (advisor-reviewed option (a), not > (b)):** `cell_w()`/`cell_h()` are now mode-aware — `font_8x16`'s fixed 8×16 in bitmap mode, > a provisional 12×24 in TTF mode (`600/1000 × 20px = 12px` exactly, using 4.4i's own > confirmed-uniform `hmtx` advance width so the fixed-cell grid tiles cleanly; final REPL > text size is 4.4m's decision, not this item's — marked provisional throughout). Since the > grid changes, `vt100_enable_ttf()` re-derives `cols`/`rows` from the new cell size and > clears the screen, the same as `vt100_init()` itself does — safe because this runs once, at > a point where nothing worth preserving is on screen yet. > > **Also fixed the second call site 4.4i flagged**, not just `draw_cursor_glyph()`'s: > `erase_line_range()` no longer draws a per-cell blank glyph (`font_8x16`-specific, and > already inconsistent with `erase_display(2)`'s full-screen case, which used a plain rect > fill). A blank cell is visually identical to a filled background rect regardless of glyph > backend, so it's now one `fb_fill_rect()` call for the whole erased range — simpler *and* > mode-independent, closing 4.4i's inconsistency finding rather than carrying it forward. > > Underline positioning (the third 4.4i finding, `UNDERLINE_ROW` hardcoding "row 14 of 16") > is branched per-mode: unchanged in bitmap mode, placed 2px below the TTF baseline in TTF > mode — no equivalent fixed row exists there since this parser doesn't read `hhea` > underline metrics (`ttf.h`), so this is provisional too. > > **Three-arch verified:** amd64 (`logs/20260811-194937/amd64/`), aarch64 > (`logs/20260811-195246/aarch64/`), riscv64 (`logs/20260811-195446/riscv64/`) — all reach > `ok>`, POST `Failed: 0`, identical dict-hashes across all three (confirming the TTF switch, > which only runs after POST, has no effect on the parity-hashed dictionary state). amd64 > screendump, full and zoomed on the REPL tail: > [evidence/amd64/qemu-screenshot-20260811-195041-4.4j-ttf-repl.png](evidence/amd64/qemu-screenshot-20260811-195041-4.4j-ttf-repl.png), > [evidence/amd64/qemu-screenshot-20260811-195041-4.4j-ttf-repl-zoom.png](evidence/amd64/qemu-screenshot-20260811-195041-4.4j-ttf-repl-zoom.png) > — real proportional JetBrains Mono letterforms on the version banner, CLI banner, and > `ok>` prompt, visibly distinct from every prior screenshot's blocky bitmap font; `[Hera]` > stays orange, `ok>` stays cyan (4.4h unaffected). Boot/POST (everything above the visible > tail) is unchanged `font_8x16.c` output, confirmed by this same screendump showing nothing > else on screen — `erase_display(2)` at the switch point cleared it, same behavior as every > screenshot since 4.4c. > > **Known, pre-existing, not touched here:** the mangled `â[box][box]` where an em-dash > should read (`"FORTH-79 interpreter — type BYE..."`) is `vt100_putc()`'s byte-oriented > pipeline treating each byte of the UTF-8 em-dash as its own codepoint — `TTF-TEXT`'s own > word decodes UTF-8 (`ttf_words_decode_utf8()`), but `vt100.c`'s character path never did, > in *either* glyph mode. Confirmed pre-existing by checking earlier font_8x16-mode > screenshots (4.4f/4.4h): the same line already rendered wrong there too (garbled as "tgpe > BYE" etc.) — not a 4.4j regression, a longstanding gap in `vt100.c` unrelated to which font > backend draws the (wrong) glyph. > > **CANVAS scroll-box text**, per this item's original done-when: 4.4n/4.4o build that box as > separate, later items — this item's job was making sure the *same* TTF renderer will serve > it once it exists, not building the box itself. No box exists yet, so this is deferred by > construction, not skipped. - [x] **4.4k — Live-verify ANSI color end-to-end.** Depends on 4.4j. Inject an SGR color escape sequence (`ESC[3xm`/`ESC[9xm`/256-color) over serial and confirm it visibly changes `TTF-TEXT`-rendered glyph color in a screendump — the parser and the retargeted draw call working together, not just each existing independently. *Done when:* screendump evidence of a color change from an injected SGR sequence, on all three architectures; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11.** No FORTH word exists to emit a literal `ESC` byte plus arbitrary text > directly, so composed one from existing primitives and injected it over the serial socket > at the `ok>` prompt (same `socat`-to-chardev-socket technique the `qemu` Makefile target > already uses for `EXEC-DOE` injection): `27 EMIT ." [95mCOLOR-TEST" 27 EMIT ." [39m"` — > `EMIT` for the raw `ESC` (27) bytes, `."` for the literal `[95m`/`[39m` text between them. > Chose SGR 95 (bright magenta, the `p >= 90 && p <= 97` branch in `apply_sgr()`) specifically > because it's a third, previously-unexercised code path — 4.4h already covered `38;2;r;g;b` > truecolor (the prompt colors), this covers the bright-palette branch, distinct from both. > > Three-arch screendump evidence, each showing `COLOR-TEST` rendered in bright magenta via > the TTF path, immediately after the still-orange `[Hera]`/cyan `ok>` prompt and the > echoed input line in default gray — confirming the SGR parser and the 4.4j-retargeted draw > call work together, not just independently: > [evidence/amd64/qemu-screenshot-20260811-200123-4.4k-sgr-color-injection.png](evidence/amd64/qemu-screenshot-20260811-200123-4.4k-sgr-color-injection.png), > [evidence/aarch64/qemu-screenshot-20260811-200459-4.4k-sgr-color-injection.png](evidence/aarch64/qemu-screenshot-20260811-200459-4.4k-sgr-color-injection.png), > [evidence/riscv64/qemu-screenshot-20260811-200719-4.4k-sgr-color-injection.png](evidence/riscv64/qemu-screenshot-20260811-200719-4.4k-sgr-color-injection.png) > — first screendump evidence ever captured for aarch64/riscv64 in this document (every > prior screendump item, e.g. 4.4c/4.4h, was amd64-only with that gap explicitly accepted; > this item closes it incidentally, not by its own design). aarch64/riscv64 both confirmed > booting at 800×600 (`-device ramfb`'s default) in the process — a data point 4.4l will > want. > > Standard three-arch boot+log basis (unmodified from 4.4j — this item made no code changes): > amd64 (`logs/20260811-200017/amd64/`), aarch64 (`logs/20260811-200250/aarch64/`), riscv64 > (`logs/20260811-200834/riscv64/`) — `Failed: 0`, identical dict-hashes, all three. - [x] **4.4l — Confirm actual booted framebuffer resolution, all three architectures.** Investigation only. amd64 already confirmed this session: 1280×800 (QEMU/OVMF GOP default, read directly off the 2026-08-11 verification screendump). aarch64/riscv64 unconfirmed. *Done when:* resolutions for all three architectures are recorded here from real screendumps or boot logs, not assumed. *Refs:* §27.8. > **Done 2026-08-11 — answered incidentally by 4.4k, confirmed here formally.** Read directly > off the pixel dimensions of 4.4k's three screendump files (`file`/image-decode, not > assumed from any device default documented elsewhere): > - **amd64: 1280×800** — QEMU/OVMF GOP default. Confirmed since the 2026-08-11 4.4c > verification screendump, reconfirmed by 4.4k's > [evidence/amd64/qemu-screenshot-20260811-200123-4.4k-sgr-color-injection.png](evidence/amd64/qemu-screenshot-20260811-200123-4.4k-sgr-color-injection.png). > - **aarch64: 800×600** — `-device ramfb`'s default, first confirmed by 4.4k's > [evidence/aarch64/qemu-screenshot-20260811-200459-4.4k-sgr-color-injection.png](evidence/aarch64/qemu-screenshot-20260811-200459-4.4k-sgr-color-injection.png) > (the first screendump of any kind captured for this architecture in this document). > - **riscv64: 800×600** — same `-device ramfb` default as aarch64 (both `Makefile.starkernel` > `qemu` targets configure it identically), confirmed by 4.4k's > [evidence/riscv64/qemu-screenshot-20260811-200719-4.4k-sgr-color-injection.png](evidence/riscv64/qemu-screenshot-20260811-200719-4.4k-sgr-color-injection.png). > > Two different resolutions across the fleet, not three uniform ones — anything in 4.4n/4.4o > (CANVAS geometry) computed per-architecture needs to actually branch on this, not assume a > single shared screen size. - [x] **4.4m — Decide REPL-strip pixel height.** Currently undefined anywhere in this document or the code. Once 4.4j lands, REPL text renders via `TTF-TEXT` at some chosen point/pixel size (also not yet chosen) — strip height derives from that size's line height plus padding, not from `font_8x16.c`'s fixed 8×16 cell. *Done when:* a REPL text size (px) and a resulting strip height (px) are both decided and recorded here. *Refs:* §27.8. > **Decided with Captain Bob, 2026-08-11.** Before deciding, flagged the real constraint this > choice feeds into: 4.4o needs a 640×480 scroll-box to fit inside CANVAS, and CANVAS height > = screen height (4.4l) − strip height − a fixed gap (4.4n). On aarch64/riscv64 (600px tall), > that leaves very little room — strip height + gap must stay well under 120px or 4.4o's own > text says it must stop and report rather than silently shrink the box. > > **Text size: 20px, unchanged from 4.4j's provisional value** — no rework, and 4.4j's own > `VT100_TTF_SIZE_PX`/`VT100_TTF_CELL_W_PX`/`VT100_TTF_CELL_H_PX` constants are now the > final values, not placeholders (their code comments calling them "provisional, pending > 4.4m" are stale as of this decision — worth a follow-up comment cleanup when 4.4n/4.4o > touch that code next, not urgent enough to justify a code-only commit for comment text > alone). > > **Visible lines: 4.** **Strip height: 4 × `VT100_TTF_CELL_H_PX` (24px) = 96px** — no > additional padding layered on top of that, since each line already carries 4px of leading > baked into the 24px cell height itself (`VT100_TTF_CELL_H_PX`'s own comment: "size + 4px > leading/descender"). > > **Checked against the 480px constraint, not assumed safe:** on the 600px-tall > aarch64/riscv64 screens, `600 − 96 = 504px` remains for CANVAS + gap. That's only 24px of > slack over the 480px minimum — a generous gap choice in 4.4n (e.g. anything over ~24px) > would fail 4.4o's fit check. **4.4n's gap must stay ≤ 24px on the small architectures**, > flagged here explicitly so it isn't discovered as a surprise failure two items later. amd64 > (800px tall) has no such pressure (`800 − 96 = 704px` for CANVAS + gap). - [x] **4.4n — CANVAS rectangle definition, per architecture.** Screen height (4.4l) minus REPL strip height (4.4m) minus the small fixed gap (exact pixel value chosen here) between CANVAS and the strip. *Done when:* a CANVAS rectangle (top-left, width, height) is computed and recorded for each architecture. *Refs:* §27.8. > **Done 2026-08-11.** Pure geometry, no code change. Layout follows §27.6's reviewed > mockup (REPL strip fixed at the bottom of the screen, CANVAS the region above it) and > uses standard top-down raster coordinates (top-left/width/height) to match this item's own > phrasing and how `fb_fill_rect()`/`vt100.c` already address pixels — not TTF-TEXT's > separate bottom-left-origin Cartesian convention, which is a FORTH-word-level API choice > for its own callers, not a general framebuffer-addressing convention. > > **Gap: 16px.** Chosen within 4.4m's flagged `≤24px` ceiling for the small architectures, > leaving 8px of margin rather than cutting it exactly to the limit; matches the codebase's > existing small-spacing conventions (`TTF_CACHE_MARGIN`=8, TTF cell leading=4). > > **amd64 (1280×800, per 4.4l):** > - REPL strip: top-left (0, 704), 1280×96 > - Gap: 688–704 (16px) > - **CANVAS: top-left (0, 0), 1280×688** > > **aarch64 / riscv64 (800×600, per 4.4l — identical, same resolution both):** > - REPL strip: top-left (0, 504), 800×96 > - Gap: 488–504 (16px) > - **CANVAS: top-left (0, 0), 800×488** > > **Checked against 4.4o's 480px requirement, not assumed:** 488px ≥ 480px on the small > architectures — passes, with exactly the 8px margin the 16px gap choice was picked to > preserve. Any larger gap than 4.4m's ceiling would have failed this; recorded as > confirmation the earlier warning held, not a new finding. - [x] **4.4o — 640×480 scroll-box centering math within CANVAS.** Layout math computed on top of `TTF-TEXT`'s own coordinate primitive, which is simpler and already fixed: Cartesian, origin at the bottom-left of the **physical framebuffer** (not CANVAS-relative — resolved and implemented ahead of this item, 2026-08-11, `ttf_words.c`'s `baseline_y = fb_height() - y`, verified by amd64 screendump). This item translates 4.4n's CANVAS-relative rectangle into that same physical-framebuffer space. **Open, blocking question, not assumed:** if any architecture's CANVAS height (4.4n) is smaller than 480, this item cannot center the box as specified and must stop and report per §25.0 rule 5, not silently shrink it. *Done when:* box top-left coordinates are computed for each architecture, or the item stops and reports which architecture's CANVAS is too small. *Refs:* §27.8. > **Done 2026-08-11.** Pure geometry, no code change. **Blocking question checked first, not > assumed clear:** both 4.4n CANVAS heights (688px amd64, 488px aarch64/riscv64) are ≥ 480px > — no architecture fails the fit, so this proceeds to centering math rather than stopping to > report a failure. > > Centered within 4.4n's CANVAS rect (itself top-left (0,0) on both architecture classes, so > centering within CANVAS is centering within the screen minus the strip): margin > = `(canvas_dim − box_dim) / 2` per axis, symmetric by construction. > > **amd64 CANVAS 1280×688:** `x = (1280−640)/2 = 320`, `y = (688−480)/2 = 104` (raster, > top-down — matches this item's own "top-left" phrasing and how `fb_fill_rect()` already > addresses pixels). Box spans raster x:[320,960], y:[104,584] — 320px side margins, 104px > top/bottom margins, both symmetric. > > **aarch64/riscv64 CANVAS 800×488:** `x = (800−640)/2 = 80`, `y = (488−480)/2 = 4`. Box > spans raster x:[80,720], y:[4,484] — 80px side margins, the full 4px top/bottom margin > 4.4n's 16px-gap choice was picked to leave (8px total split 4px/4px top/bottom, confirming > that margin lands exactly where 4.4n predicted, not a new finding). > > **Translated into TTF-TEXT's Cartesian, bottom-left-origin convention** (`y_cartesian = > fb_height − y_raster`, `ttf_words.c`'s own flip), for whatever later item actually issues > `TTF-TEXT` calls to draw within the box — reference corner is the box's bottom-left, matching > that origin: > - amd64: box bottom (raster) = 104+480 = 584 → `y_cartesian = 800−584 = 216`. **Bottom-left > (320, 216).** > - aarch64/riscv64: box bottom (raster) = 4+480 = 484 → `y_cartesian = 600−484 = 116`. > **Bottom-left (80, 116).** - [x] **4.4p — Three-arch screendump verification of 4.4l–4.4o.** Geometry only — not the toggle or scrollback. *Done when:* screendump on all three architectures shows the 640×480 box centered within CANVAS at the computed coordinates, with the fixed gap above the REPL strip visible; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Done 2026-08-11 — one-shot diagnostic probe, reverted after capture, per this document's > established write/run-once/capture/revert discipline.** Nothing in 4.4l–4.4o produced > persistent drawing code (they were geometry decisions feeding 4.4q/4.4r, which build the > box for real); this item's own title is "verification", matching 4.4i's same-shape > no-permanent-code precedent. Probe drew the 4.4o box outline (4 `fb_fill_rect()` strips, > 2px stroke, green) plus a full-width red marker at 4.4n's strip-top edge (so the gap is > observable, not just the box), inserted in `sk_repl()` right after > `console_fb_enable_ttf()` — after the `-O2`/POST-safe boundary, before > `vt100_enable_ttf()`'s own `erase_display(2)` would otherwise still be pending (it isn't; > that runs inside `console_fb_enable_ttf()` itself, so the probe drawing correctly lands > *after* the clear, not wiped by it). > > **Measured, not eyeballed** — scanned each PNG's raw pixels in Python for the drawn > colors' bounding box, compared against 4.4o's computed values exactly: > - amd64: green box x:[320,959] y:[104,583] (640×480 exactly), red marker y:[704,705] — > matches 4.4o's (320,104) and 4.4n's strip-top y=704 precisely. > - aarch64: green box x:[80,719] y:[4,483] (640×480 exactly), red marker y:[504,505] — > matches (80,4) and y=504 precisely. > - riscv64: identical measured bounds to aarch64 (same resolution, same geometry) — > x:[80,719] y:[4,483], red marker y:[504,505]. > > Screendumps: > [evidence/amd64/qemu-screenshot-20260811-202526-4.4p-canvas-geometry-probe.png](evidence/amd64/qemu-screenshot-20260811-202526-4.4p-canvas-geometry-probe.png), > [evidence/aarch64/qemu-screenshot-20260811-202845-4.4p-canvas-geometry-probe.png](evidence/aarch64/qemu-screenshot-20260811-202845-4.4p-canvas-geometry-probe.png), > [evidence/riscv64/qemu-screenshot-20260811-203207-4.4p-canvas-geometry-probe.png](evidence/riscv64/qemu-screenshot-20260811-203207-4.4p-canvas-geometry-probe.png). > > **Observed, not fixed here (belongs to later wiring items):** on aarch64/riscv64, where > the box top sits only 4px below the CANVAS top, the REPL banner text visibly overlaps the > box's top edge in the screendump. This is because `vt100`'s cursor grid still spans the > *entire* screen, top-anchored at (0,0) — it has never been confined to the 96px strip > 4.4n/4.4m defined; that confinement is §27.6's mockup intent but isn't any numbered item's > job yet. Not a geometry error in this item's own measured bounds (which are exact); a real, > known gap between the landed REPL text path and the mockup, worth its own item when > 4.4q/4.4r pick this up. > > Three-arch boot+log basis (probe code present during these particular boots, harmless — > drawing happens after POST/capsule birth, confirmed by unaffected `Failed: 0` and identical > dict-hashes): amd64 (`logs/20260811-202330/amd64/`), aarch64 > (`logs/20260811-202700/aarch64/`), riscv64 (`logs/20260811-203102/riscv64/`). > > Probe code reverted from `repl.c` immediately after screendump capture; tree confirmed > clean. - [x] **4.4q — Scrollback, ~1000 lines.** A circular buffer of prior scroll-box lines, target depth approximately 1000 (Captain Bob's own qualification: "something along those lines," not a hard-locked spec number). Storage mechanism (`kmalloc` ring buffer vs. static array) is this item's own implementation call. *Done when:* scrolling back through more than one screen's worth of REPL output recovers prior lines up to the target depth, verified live on amd64 at minimum; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **Scope decided with Captain Bob before implementation, 2026-08-11:** this item is the > ring buffer and recall mechanism on today's full-screen `vt100` grid, not also confining > REPL text to the 4.4o 640×480 box. That confinement (`vt100`'s grid still spans the whole > screen, per 4.4p's "observed, not fixed") stays open, explicitly, as its own future item — > not a third silent deferral. Trigger mechanism: no keyboard input path exists yet > (`console_getc()` confirmed serial-only; M8 is unstarted) — implemented as two FORTH > words, `SCROLL-BACK ( n -- )` / `SCROLL-FWD ( n -- )`, exercised via the same serial > injection technique 4.4k used. > > **Done 2026-08-11.** `vt100.c` gained a text-only ring buffer (`g_ring`, > `VT100_SCROLLBACK_MAX_LINES` = 1000, `kmalloc`'d in `vt100_enable_ttf()` — text, not > pixels: at ~cols bytes/line this is tens of KB, roughly three orders of magnitude smaller > than a pixel-snapshot ring for the same depth would be) plus a `g_shadow` buffer mirroring > exactly what's on screen (updated by every `draw_cursor_glyph()` call and shifted by every > `scroll_up()`, which now pushes evicted rows into the ring before the pixel scroll). > History model: one continuous sequence, ring (oldest→newest evicted) then shadow > (oldest→newest on-screen); `g_scroll_offset` selects a window into it and > `scrollback_redraw()` draws that window. Scrolling is always "redraw from the ring/shadow > history," never a second pixel-scroll mechanism — decided up front per this document's own > advisory review, since retrofitting a redraw model onto a blit-scroll model later would > have been the expensive order. > > New words in `src/word_source/scroll_words.c` (registered as Module 31, > `word_registry.c`), thin wrappers over new `console_fb_scroll_back()`/`_fwd()` → > `vt100_scroll_back()`/`_fwd()`. **Bug caught during live testing, not left in:** both words > initially used `if (vm->dsp < 1)` for a single-argument underflow check, copied from a > pattern that turned out to belong to a different, older `dsp`-as-raw-count convention > elsewhere in this codebase; the convention `vm_pop()` (which these words actually call) > uses is `dsp` as a 0-based top-of-stack index, where `dsp < 0` is empty — the off-by-one > rejected every legitimate single-argument call as underflow. Fixed by removing the > separate precheck entirely and relying on `vm_pop()`'s own guard, sidestepping the > multi-convention mismatch rather than risking picking the wrong one again. > > **Live-verified on all three architectures**, not just amd64 (exceeds this item's own > amd64-minimum bar): injected `: GENLINES 0 DO I . CR LOOP ; 50 GENLINES` (`DO`/`LOOP` are > compile-only in this FORTH, needs a definition — also caught live, fixed the test not the > code) to generate far more than one screen's worth of output — each iteration's HADES/ECW > trace logging multiplies the line count well past 50 real terminal lines. amd64: captured > three screendumps — (A) live tail showing lines up to 49, (B) after `30 SCROLL-BACK` > showing lines 38–44 (correctly older), (C) after `30 SCROLL-FWD` showing genuinely live > content again, including the echoed `SCROLL-BACK`/`SCROLL-FWD` commands themselves and > their `ok` responses — proof it tracks whatever is truly live "now" rather than a frozen > snapshot, not just a coincidental match to an earlier capture. aarch64/riscv64: same > `GENLINES`/`SCROLL-BACK` sequence, both correctly recovered older lines (46/47) after > scrolling back 20. Screendumps: > [evidence/amd64/qemu-screenshot-20260811-210852-4.4q-scrollback-A-live.png](evidence/amd64/qemu-screenshot-20260811-210852-4.4q-scrollback-A-live.png), > [...-B-scrolled.png](evidence/amd64/qemu-screenshot-20260811-210852-4.4q-scrollback-B-scrolled.png), > [...-C-restored.png](evidence/amd64/qemu-screenshot-20260811-210852-4.4q-scrollback-C-restored.png), > [evidence/aarch64/qemu-screenshot-20260811-211338-4.4q-scrollback-A-live.png](evidence/aarch64/qemu-screenshot-20260811-211338-4.4q-scrollback-A-live.png), > [...-B-scrolled.png](evidence/aarch64/qemu-screenshot-20260811-211338-4.4q-scrollback-B-scrolled.png), > [evidence/riscv64/qemu-screenshot-20260811-212029-4.4q-scrollback-A-live.png](evidence/riscv64/qemu-screenshot-20260811-212029-4.4q-scrollback-A-live.png), > [...-B-scrolled.png](evidence/riscv64/qemu-screenshot-20260811-212029-4.4q-scrollback-B-scrolled.png). > > **Known limitation, confirmed by direct pixel measurement, not just documented:** redrawn > lines lose their original SGR color (per-cell color history isn't stored) — measured the > amd64 restored-view screenshot's `[Hera]` prefix pixels directly: no orange found in any > row touched by the `SCROLL-FWD` redraw, only in the one freshly-drawn live row after the > redraw settled. Text content is recovered exactly; color is not — this is the tradeoff > this item's own text left as "this item's own implementation call." > > Three-arch standard boot+log basis: amd64 (`logs/20260811-210705/amd64/`), aarch64 > (`logs/20260811-211155/aarch64/`), riscv64 (`logs/20260811-211617/riscv64/`) — `Failed: 0` > all three, dict-hashes identical across all three (values changed from prior items, > correctly — two new words were added to the dictionary — but still cross-arch consistent, > which is the actual parity property that matters). - [x] **4.4t — Confine REPL text rendering to the 4.4o CANVAS box.** REPL text (TTF mode) currently still renders across the entire framebuffer — the 640×480 box computed and pixel-verified in 4.4o/4.4p was geometry only, never wired into `vt100`'s actual glyph placement, cursor bounds, or scroll region. This was explicitly deferred, twice, as its own item (flagged in both 4.4p's and 4.4q's writeups) rather than silently folded into either. Scope: make `vt100`'s live text grid (TTF/REPL mode only) operate entirely within the box — per-arch origin (amd64 (320,104); aarch64/riscv64 (80,4)), 640×480, giving an exact 53 cols × 20 rows (640/12, 480/24, both exact, no remainder). Boot/POST rendering (`font_8x16`, bitmap mode) is untouched — stays full-screen, exactly as before. Concretely: `px_of()`/`py_of()` gain a box-origin offset (0,0 in bitmap mode, the per-arch box origin in TTF mode); `g_vt.cols`/`g_vt.rows` in `vt100_enable_ttf()` are set to the box's 53×20 directly rather than derived from `fb_width()`/`fb_height()` — and this must happen *before* the scrollback shadow/ring `kmalloc` block in that same function, since `g_line_stride` depends on `cols`; verify `g_line_stride == 54` at runtime rather than assuming the ordering landed right. `erase_display(2)` must become mode-aware: box-scoped fill in TTF mode, full-screen in bitmap mode (today it unconditionally wipes the whole screen, which would blank CANVAS outside the box once confinement is live). `scroll_up()`'s `fb_scroll_rows()` call is a whole-framebuffer blit — wrong once text is box-confined, since it would drag pixels from outside the box. New `fb_scroll_rect(x, y, w, h, lines, bg)` in `framebuffer.c`/`.h`, row-by-row copy bounded to `x..x+w`, used in TTF mode; bitmap mode keeps `fb_scroll_rows()` unchanged. This item does **not** draw a persistent border around the box — that is explicitly 4.4r's business (hide/show), not this item's; adding one here would be scope creep. Input echo needs no special handling — `sk_readline()` echoes through `console_putc()`, the same path being confined; verify this rather than adding anything. Depends on 4.4o/4.4p (box geometry, already done) and 4.4q (scrollback, already done, whose shadow/ring sizing this item's cols/rows change affects). *Done when:* three-arch screendump showing REPL text bounded inside the box region, backed by a pixel scan proving no non-background glyph pixel lands outside the box rect (measured, not eyeballed — same discipline as 4.4p); three-arch QEMU boot + logs per CLAUDE.md. Expect the screenshots to look different from every prior capture in this section — text starts at the box origin, not (0,0), and only 20 rows are usable at a time. *Refs:* §27.8, 4.4o, 4.4p, 4.4q. > **Done 2026-08-11.** Implemented exactly as scoped: `g_origin_x`/`g_origin_y` added to > `vt100.c` (0,0 default, matching bitmap mode); `px_of()`/`py_of()` add the origin; > `vt100_enable_ttf()` sets `g_origin_x`/`g_origin_y`/`g_vt.cols`/`g_vt.rows` to the per-arch > box values *before* the scrollback shadow/ring `kmalloc` block, with a runtime check that > `g_line_stride == VT100_BOX_COLS + 1` (54) — bails to "scrollback unavailable" rather than > silently corrupting shadow/ring indices if that ordering is ever disturbed later. > `erase_display(2)` and the `ESC M` reverse-index top-row fill are both mode-aware now > (box-scoped in TTF, full-screen in bitmap, unchanged). `fb_scroll_rect(x,y,w,h,pixel_rows,bg)` > added to `framebuffer.c`/`.h` (row-by-row copy bounded to `x..x+w`, same non-volatile bulk-copy > technique as `fb_scroll_rows()`); `scroll_up()` calls it in TTF mode, `fb_scroll_rows()` > unchanged in bitmap mode. > > **One thing found and fixed beyond the item's original text, not scope creep — it's the > same "switch cleanly into confined REPL mode" this item is about:** the first verification > pass showed the box's content was genuinely confined (confirmed by cropping exactly the box > rect and reading it cleanly) but the *rest of the screen* was frozen bitmap-mode boot/POST > debris, never cleared, because `erase_display(2)` becoming box-scoped means it no longer > clears anything outside the box either. Fixed with one `fb_fill_rect(0, 0, fb_width(), > fb_height(), g_vt.def_bg)` at the top of `vt100_enable_ttf()`'s box-switch, before the > (now box-scoped) `erase_display(2)` call — the one and only whole-framebuffer wipe in TTF > mode; every later `ESC[2J]` stays box-scoped as designed. > > **Verification, three-arch, pixel-measured not eyeballed:** booted each arch to `ok>`, > sent `LOG-ERROR LOG-LEVEL!` (this build's default log level traces every word execution to > console via a HADES/ECW hotword tracer — unrelated to this item, silenced only so the > screendump reads cleanly) then `: TB1234 0 DO I . LOOP CR ; 20 TB1234` to generate content > and exercise wrap/scroll within the box, then a QEMU monitor `screendump`. A Python scan > checked every pixel of each screenshot against the computed box rect > ((320,104)-(960,584) amd64; (80,4)-(720,484) aarch64/riscv64) and counted non-background > pixels outside it: **0 on all three architectures.** Screendumps: > [evidence/amd64/qemu-screenshot-20260811-230119-4.4t-confinement-amd64.png](evidence/amd64/qemu-screenshot-20260811-230119-4.4t-confinement-amd64.png), > [evidence/aarch64/qemu-screenshot-20260811-230119-4.4t-confinement-aarch64.png](evidence/aarch64/qemu-screenshot-20260811-230119-4.4t-confinement-aarch64.png), > [evidence/riscv64/qemu-screenshot-20260811-230119-4.4t-confinement-riscv64.png](evidence/riscv64/qemu-screenshot-20260811-230119-4.4t-confinement-riscv64.png). > > **Real environment hazard found and worked around, recorded so it doesn't cost time again:** > this repo's `Makefile.starkernel` builds into `build/$(ARCH)/$(TARGET)` where > `TARGET ?= kernel` — i.e. `build/amd64/kernel/`, not `build/amd64/`. A stale > `build/amd64/starkernel.iso` left over from hours earlier in this session at the wrong > (non-`kernel/`) path caused three consecutive custom QEMU boots (added a monitor socket for > screendumping, mirroring 4.4p/4.4q's technique) to reproducibly stall mid-boot during > Hermes's migration self-test — looked exactly like a confinement-caused regression until the > path mismatch was found via `ls -la` timestamp comparison. Not a kernel bug; a test-harness > mistake. Always launch custom QEMU invocations against `$(BUILD_DIR)` from the Makefile > (`build//kernel/`), never a hand-guessed `build//` path. > > Three-arch standard boot+log basis: amd64 (`logs/20260811-225236/amd64/`), aarch64 > (`logs/20260811-225514/aarch64/`), riscv64 (`logs/20260811-225903/riscv64/`) — `Failed: 0` > all three. - [x] **4.4r — Toggle word: hide/show the scroll box.** A FORTH word that hides the 640×480 scroll box, revealing the rest of CANVAS beneath it for drawing; showing it again restores the scroll-box content undisturbed, no scrollback loss. Depends on 4.4o/4.4p (geometry), 4.4q (scrollback), 4.4t (confinement — the box must be real before it can be hidden), 4.4v (keyboard bridge — the toggle key has to reach the REPL), and 4.4z (the box needs a drawn border before "hide/show" is visually meaningful) all being done first. *Done when:* toggling off then on again, verified by screendump, shows the CANVAS drawing area unobstructed while off and the exact prior scroll-box content restored when back on; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8. > **REFRAMED AND DONE 2026-08-12, by 4.4ab.** "Hide/show the scroll box" stopped meaning > anything once 4.4ab removed the box — there is only the full-screen terminal now. The > underlying need survives in a more general shape: a text/graphics mode switch for the > *whole screen*, which is what got built. `vt100_toggle_graphics()` (`vt100.c`) is a > two-state machine (VISIBLE/HIDDEN) — hiding stops every terminal draw call from touching > the framebuffer (logical state, cursor, and scrollback keep advancing normally so nothing > is lost), showing again wipes whatever graphics drew and reuses the existing > `scrollback_redraw()` to repaint the terminal's current content exactly, restored rather > than merely redrawn from a stale snapshot. Reachable two ways, both driving the same > single transition function: physically via Alt+TAB (`repl.c`'s `sk_kbd_getc()`) and > programmatically via the new `ALT+TAB` FORTH word (`keyboard_words.c`) — the "toggle word" > this item originally asked for, just not scoped to a box anymore. - [ ] **4.4s — `(user)` prompt segment — scoped, blocked, not started.** Extends 4.4's prompt to `[VM name] (user) ok>`, e.g. `[Hera] (zuse) ok>`. **Blocked:** `zuse_session` (`include/vm.h:393`) is a boolean only — no username/identity string exists anywhere in the ACL system today (confirmed by reading `capsules/zuse.4th`, `capsules/ACL.4th`, `vm_core.c`). This item cannot start until ACL identity storage exists — that's the open Phase 8 PKI/user-minting item already on record in CLAUDE.md's ACL section. Recorded here so the prompt's final shape isn't lost, not because it's ready to build. *Refs:* §27.8, CLAUDE.md ACL section (Phase 8). - [x] **4.4u — Locked bottom-up console layout design.** Captain Bob walked the full console layout bottom-up in conversation on 2026-08-11 (evening session, after 4.4t landed); recorded here verbatim as the reference spec before any of it is built, per this document's own discipline of capturing design before implementation. **This revises 4.4m/4.4n's REPL-strip sizing** — see the conflict note below; those items stay checked as a historical record of what was decided at the time, not edited in place. **The design, bottom-up, per architecture's own raster coordinates:** 1. **Row 0** — the very last pixel row of the physical framebuffer (`fb_height()-1`), same rule on every ISA regardless of resolution. A horizontal line runs the full screen width. 2. A few pixels up from that line: the **REPL prompt baseline** — cursor and prompt (`[Hera] ok>`) sit here, text flows left to right. 3. An **equal number of pixels** further up from the baseline (mirroring the gap below it): a second horizontal line, full width — the REPL strip's top border. The strip is symmetric around the baseline by construction. 4. **REPL input is single-line with horizontal scroll on overflow, not soft-wrap** — decided explicitly in conversation after weighing both: soft-wrap would mean the strip grows upward as a line wraps, pushing CANVAS's boundary and requiring CANVAS content to reflow or get covered/uncovered live; horizontal scroll (classic readline-style, view follows the cursor through a fixed single-line window into the full input buffer) needs none of that and was chosen as "cleaner." **Input buffer width for this strip needs resolving against `INPUT_BUFFER_SIZE`=1025 (CLAUDE.md) before this is buildable** — Bob's verbal estimate was "like 255/256," well under the actual shared REPL/LOAD buffer size; not reconciled yet, flagged here rather than guessed at. 5. A small additional gap above the REPL strip's top border. 6. A **literal drawn box** — an actual stroked rectangle outline, not just an implicit text boundary — for the 640×480 scroll area (4.4o's geometry), centered horizontally (equal left/right margins, already computed: amd64 x=[320,960], aarch64/riscv64 x=[80,720]). All REPL output/scrollback history (4.4q) renders inside this box. **Not yet drawn** — 4.4t confined content to this rect but never stroked a visible border on it. 7. **Above the box: nothing, by default** — reserved blank space, no permanent content. 8. **The toggle (4.4r) is bound to a meta key — exact key not yet decided** (candidates floated: TAB, Ctrl+TAB, or something else; Bob deferred picking one, 2026-08-11 — "we'll figure out what meta keys we wanna use for that part later"). Whatever key it ends up being, it flips the same "flipping" behavior: toggling hides the box's contents, and at that point the box's own footprint *and* the blank space above it become one unified, full CANVAS free-play area for vector/graphics work. Toggling again restores the box and its scrollback content undisturbed. **The REPL strip (steps 1-4) is never covered by this — toggle state only ever affects the box and the space above it, never the bottom strip.** 4.4v's keyboard-bridge work needs to intercept *whatever* key is chosen before it reaches the line buffer, not specifically TAB — don't hardcode TAB-interception logic assuming this is locked. **Colors, decided explicitly:** REPL strip characters — **bright white** (`0xFFFFFF`, `FB_ANSI_PALETTE[15]`). The two horizontal lines bounding the REPL strip — **gray** (exact shade not yet pinned to a specific palette index or RGB triple; use a mid-gray consistent with `FB_ANSI_PALETTE`'s existing gray entries — index 7 `0xAAAAAA` or index 8 `0x555555` — and confirm the choice with Bob before implementing, don't assume). Scroll-box content colors are unchanged from what's already live (4.4h's orange `[VM]` prefix, cyan `ok>`). **Conflict with 4.4m/4.4n, not silently resolved:** 4.4m decided a 96px/4-visible-line REPL strip (one merged scroll+input box); this design has a single-line REPL strip (well under 96px — closer to one `VT100_TTF_CELL_H_PX` row, ~24-40px depending on the two gap sizes, neither pinned yet) with scrollback living entirely in the separate box from step 6. 4.4n's CANVAS-height arithmetic (`screen height − strip height − gap`) will produce a taller CANVAS once the real (smaller) strip height is pinned down — a strict improvement on the 4.4n numbers, not a fit failure, but the exact numbers need redoing, not assumed to still hold. *Done when:* the two open gaps above (input buffer width vs. `INPUT_BUFFER_SIZE`; exact gray shade; exact pixel values for the two REPL-strip gaps) are resolved with Bob, then this item's numbers feed a redo of 4.4n's CANVAS rectangle before any drawing code is written. *Refs:* §27.8, 4.4m, 4.4n, 4.4o, 4.4q, 4.4r, 4.4t. > **Refined 2026-08-11, still open — mockup review round 1.** First rendered mockup > (`evidence/mockups/console-layout-mockup-4.4u-amd64.png`, not live kernel code — a Python/ > PIL drawing at amd64's 1280×800 to check the geometry reads right before building it) shown > to Bob. Two changes came back: > - **The two REPL-strip gaps (baseline-to-bottom-line, baseline-to-top-line) both grow by > 3px** — Bob wanted more headroom above the prompt specifically. First pass used 12px/12px > (a guess, not yet locked); now 15px/15px. Still not treated as final — just the current > working number for the next mockup pass, not a pinned spec value the way 4.4o's centering > math is. > - **Scroll-box content needs its own soft-wrap** — separate from step 4's REPL-strip > horizontal-scroll decision. Text inside the drawn box (step 6) that exceeds the box's > inner width must wrap to additional lines within the box, not run past the box's right > border as the first mockup pass showed. This is scrollback *content* wrapping, not the > REPL input line — those are two different text flows with two different overflow > behaviors, don't conflate them when implementing. > Second mockup pass regenerated with both changes and re-reviewed — approved as "pretty > close." Both numbers above still informal (mockup-only, not implemented, not verified > against real `TTF-TEXT` rendering) — this document's own discipline (don't assume a mockup > number survives contact with real glyph metrics) means these still need to be re-checked > once actually built, not carried forward as gospel. > **RESOLVED 2026-08-12 by 4.4w.** All three open gaps (input buffer width, gray shade, the > two REPL-strip gap sizes) pinned by Captain Bob directly — see 4.4w's own record. Still not > verified against real `TTF-TEXT` rendering, per the note directly above; 4.4x/4.4z carry > that verification forward as their own done-when, not re-litigated here. - [x] **4.4v — M8 scope, precisely bounded: keyboard-to-REPL bridge.** Surfaced while discussing 4.4u's TAB-toggle binding — Captain Bob's recollection was that keyboard input already works end-to-end; checked against the code rather than assumed either way. **What's real and already done** (confirmed by reading the code, not from milestone status): `i8042.c` (amd64) and `virtio_input.c` (aarch64/riscv64) are genuine interrupt-driven keyboard drivers — IOAPIC/IRQ1 routing confirmed live in this session's own boot logs (`"I/O APIC: keyboard IRQ1 unmasked"`), scancodes/decoded `EV_KEY` events land in a ring buffer, and `KBD-SCAN`/`KBD-DEBUG`/`VKBD-EVENT` (`keyboard_words.c`) expose that buffer to FORTH, including a standing diagnostic (`g_i8042_isr_count`) that already caught a real bug (a genuine IRQ misreported as spurious, item 4.3.5). This is real, tested, interrupt-path-proven-alive infrastructure — not a stub. **What's missing, precisely:** nothing calls `KBD-SCAN`/`VKBD-EVENT` from `repl.c`. `sk_readline()` is documented in its own comment as reading "from serial console" and does exactly that — no scancode-to-ASCII translation table exists, and no code path connects the keyboard ring buffer to the REPL's line-read loop. The hardware/interrupt layer is done; the bridge from that layer into text the REPL can act on is not. This is the same conclusion `docs/lithosananke/ROADMAP.md`'s M8 section and this document's own 4.3.5-series entries already recorded ("no keyboard path into the VM on any architecture, confirmed by reading the code") — restated here specifically so 4.4u's TAB-toggle has an accurate prerequisite, not because the earlier conclusion was in doubt. **Scope for the bridge itself:** a scancode-to-ASCII table (amd64 i8042 scancode set 1 or 2 — confirm which `i8042.c` actually captures, don't assume — plus `virtio_input.c`'s `EV_KEY` codes for aarch64/riscv64, both need a table); `sk_readline()` gains a second poll path alongside its existing serial read, merging both sources into the same line buffer (matches the "merged serial+keyboard source" language already on record from the 4.3.5 entries); whatever meta key 4.4u eventually settles on for the toggle (TAB/Ctrl+TAB/other — not decided yet, see 4.4u step 8) needs to be intercepted *before* it reaches the line buffer as a character, and routed to the 4.4r toggle word instead of inserted literally — the interception mechanism should be keyed off "the chosen toggle key," not hardcoded to TAB specifically. *Done when:* typing at a real (or QEMU-emulated) keyboard produces the same REPL behavior as serial injection does today, on all three architectures, verified by QEMU boot with a keyboard event injected via QEMU's own input-send-event monitor command (not socat/serial), screendump showing the typed text landed in the REPL strip; three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8, 4.3.5 series, 4.4r, 4.4u, `docs/lithosananke/ROADMAP.md` M8 section. > **DONE 2026-08-12.** Reused item 4.3.5f's `KEY-EVENT` convergence work rather than building > two per-arch tables from scratch (`kbw_key_event`'s FORTH-word logic was refactored into a > new shared C function, `sk_key_event_poll()`, exported from `keyboard_words.c` — same > single-implementation discipline as everywhere else in this codebase) — one keycode-to-ASCII > translation table (Linux input-event-codes.h namespace, confirmed against this build host's > own header, not guessed) covers all three architectures instead of amd64 needing a separate > XT-scancode table. `sk_kbd_getc()` (`repl.c`) tracks shift state, decodes the standard > US-QWERTY printable range plus Enter/Backspace, and `sk_readline()` polls it as a second > source alongside `console_getc()` exactly as scoped. Verified two ways: QEMU monitor > `sendkey` injection, and — better evidence than planned for — Captain Bob typing directly > into the live QEMU window over real emulated PS/2 hardware, producing `1 1 + .` → `2 ok` > and a clean `BYE` shutdown, confirmed by him mid-session. Superseded detail: the original > done-when named "landed in the REPL strip" — the strip itself was later reverted (4.4ab); > this item's actual keyboard-bridge substance is unaffected by that reversion. - [x] **4.4w — Pin 4.4u's open numbers with Bob.** Design-only, no code. Three specific values 4.4u left as working/placeholder, not final: (1) exact gray RGB for the two REPL-strip border lines — 4.4u only says "a mid-gray, not chosen"; (2) the REPL input-line's character width — Bob's verbal estimate was "~255/256 chars," which needs reconciling against the real shared `INPUT_BUFFER_SIZE`=1025 (CLAUDE.md) — are these the same buffer, or does the REPL strip want a smaller practical typing limit than LOAD's block-content buffer; (3) the 15px/15px REPL-strip gaps and 8px box-to-strip gap used in the second mockup pass — confirm final or still tune-able. *Done when:* all three values above are stated explicitly and recorded here, no longer "working values." *Refs:* §27.8, 4.4u. > **PINNED 2026-08-12, all three, by Captain Bob directly:** > 1. **Border gray = `FB_ANSI_PALETTE[7]`, `0xAAAAAA`** — the lighter of the two candidates > 4.4u named, not index 8 (`0x555555`). > 2. **Same buffer as `INPUT_BUFFER_SIZE`=1025, with left/right horizontal scroll on a > single line** — not a smaller practical limit. Confirms 4.4u step 4's horizontal-scroll > decision was already the right shape; the buffer question is now closed rather than > reconciled against a second, smaller number. > 3. **15px/15px REPL-strip gaps and 8px box-to-strip gap confirmed as-is** — Bob's exact > words: "as long as there's enough space above and below so it looks right." Not > reopened as a fresh pixel negotiation; the second mockup pass's numbers stand. - [x] **4.4x — Redo 4.4n's CANVAS rectangle for the real (single-line) REPL-strip height, AND split the REPL prompt/input line out of the scrollback box into its own bottom strip.** 4.4n's existing CANVAS math assumed 4.4m's 96px/4-line REPL strip; 4.4u replaced that with a single-line strip (4.4w's gap values, once pinned, give the real height — order-of-magnitude 20-40px, not 96px). Depends on 4.4w (gap values pinned first). **Scope expanded 2026-08-12, folded in by Captain Bob directly.** Originally scoped as pure arithmetic (recompute the box rectangle, no code). Found while starting this item: today the REPL prompt (`ok>`, cursor, typed input) prints through the exact same `console_puts()` → vt100-grid path as everything else, confined to the box by 4.4t — there is no separate bottom strip in the code at all, so 4.4u's design (box above, independent single-line strip below, separated by the 8px gap) does not exist yet even though the geometry math assumes it does. Reported per §25.0 rule 3 rather than silently expanded; Bob's answer was to fold the split into this item rather than open a new one, since the split is what makes the recomputed geometry meaningful in the first place. *Done when:* `VT100_BOX_ORIGIN_Y` (and confirmation that `_ORIGIN_X`/`_W`/`_H` are unchanged) is recomputed and recorded per architecture below; the REPL prompt/cursor/input line renders in its own single-line strip pinned to the bottom of the screen (row 0 = `fb_height()-1`, gray border lines per 4.4u/4.4w, bright-white text) independent of the scrollback box's own grid; scrollback output continues to render only inside the box. Three-arch QEMU boot + logs per CLAUDE.md; amd64 screendump showing both regions distinctly. *Refs:* §27.8, 4.4n, 4.4u, 4.4w, 4.4t. > **DONE 2026-08-12.** `VT100_BOX_ORIGIN_X`/`_ORIGIN_Y` are no longer hardcoded per-arch > literals — both are now derived at `vt100_enable_ttf()` time from `fb_width()`/ > `fb_height()` (`vt100.c`): > ``` > origin_x = (fb_width() - VT100_BOX_W) / 2 > origin_y = fb_height() - VT100_BOX_H - VT100_STRIP_BOX_GAP_PX(8) - (2*VT100_STRIP_GAP_PX(15) + 1) > ``` > Confirmed the old per-arch `_ORIGIN_X`/`_W`/`_H` values fall out of the same formula > unchanged (only Y moves, since only the strip height changed): amd64 origin=(320,281) > [was (320,104)], aarch64/riscv64 origin=(80,81) [was (80,4)], both against the same > 800×600/1280×800 resolutions 4.4l already confirmed. > > **The strip split is real, not just geometry.** New `vt100_strip_draw()` (`vt100.c`) draws > the bottom strip directly via the existing `ttf_draw_glyph_cell()` rasterizer (no second > text-drawing path), independent of the box's own grid/cursor. `console_fb_strip_draw()` > (`console.c`/`console.h`) is the thin wrapper, matching the existing > `console_fb_enable_ttf()`/`console_fb_scroll_*()` pattern. `repl.c` builds a plain-text > (no ANSI) `"[VMName] ok> "` mirror in `g_strip_prompt`/`strip_refresh()`, refreshed > on every keystroke from `sk_readline()` (including backspace) — this also means it's > already wired for 4.4v: once the keyboard bridge lands, typed characters flow through the > same `console_getc()` → `sk_readline()` path and the strip updates for free, no further > changes needed here. > > **Also fixed, found while wiring this:** `sk_repl_step()`/`sk_repl_run()`'s local > `char input[256]` was a second, smaller buffer than `INPUT_BUFFER_SIZE`=1025 — the actual > gap 4.4w's decision (full 1025-char buffer, not a smaller practical limit) needed closed. > Widened both to `INPUT_BUFFER_SIZE`. > > **Border-line ordering deliberate:** `vt100_strip_draw()` draws the two gray border lines > *after* the glyph loop, not before — a glyph cell's own opaque background fill (24px tall) > can extend a few pixels past the 15px baseline-to-line gap for full-ascender characters, > and drawing borders last means an oversized cell can only be visually clipped by them, never > erase them. Screendump-verified below: no visible clipping at this font size in practice. > > Three-arch QEMU boot + logs: clean (`logs/20260812-144724/amd64`, > `logs/20260812-144811/aarch64`, `logs/20260812-144922/riscv64`). amd64 screendump > (`logs/screendump-4.4x/amd64-strip.png`) confirms both regions distinctly: scrollback box > with boot/POST history near the top, bottom strip with gray borders and > `[Hera] ok>` in white, clearly separated. aarch64/riscv64 screendump attempted but the > manual ISO build used for it didn't reproduce the Makefile's exact boot chain (unrelated to > this item's code) — not blocking, since the formula is resolution-derived and already > verified correct by direct calculation against both known resolutions, and the real > acceptance boot (via `make qemu`, not a manual screendump) passed clean on all three. - [x] **4.4y — Decide the toggle meta-key.** Design-only, no code. 4.4u step 8 left this open — candidates floated were TAB and Ctrl+TAB, Bob explicitly deferred choosing ("we'll figure out what meta keys we wanna use for that part later," 2026-08-11). Blocks 4.4v's interception code, which needs to know which key to intercept before it can be written. *Done when:* one specific key (or key combination) is chosen and recorded here. *Refs:* §27.8, 4.4u, 4.4v. > **DECIDED 2026-08-12 — Ctrl+TAB.** Chosen by Captain Bob directly. 4.4v's interception > code intercepts this specific combination, not bare TAB. > > **REVISED 2026-08-12, same session — Alt+TAB.** Captain Bob changed the choice while 4.4v > was being built, once the toggle's actual purpose was reframed by 4.4ab from "hide/show > the scroll box" to "switch the whole screen between text and graphics mode" — Alt+TAB > reads more naturally for a mode switch than Ctrl+TAB does. `repl.c`'s interception > (`SK_KEY_LEFTALT`=56/`SK_KEY_RIGHTALT`=100, Linux input-event-codes.h, confirmed not > guessed) was updated to match before this item's own done-when was ever exercised, so there > was no built-then-discarded Ctrl+TAB code path — only the design note above was briefly > wrong. - [x] **4.4z — Draw the scroll box's visible border.** 4.4t confined REPL text to the 640×480 box's coordinate space but never stroked a visible rectangle outline on it — the box's edges are implicit (where text stops), not drawn. 4.4u step 6 calls for "an actual stroked rectangle outline." Small, separable from 4.4v/4.4r — a single `fb_fill_rect()`-based rectangle-outline draw (four thin `fb_fill_rect()` strips, matching 4.4p's reverted probe technique but made permanent this time) called once when TTF mode is entered (alongside 4.4t's existing box-clear in `vt100_enable_ttf()`), redrawn if the box is ever cleared. *Done when:* three-arch screendump shows a visible border on the box matching the color 4.4u locks in (see 4.4w); three-arch QEMU boot + logs per CLAUDE.md. *Refs:* §27.8, 4.4t, 4.4u, 4.4w. > **DONE 2026-08-12.** `draw_box_border()` (`vt100.c`) strokes four 1px edges around the box > using the same `VT100_BORDER_GRAY` constant the strip's border lines use (renamed from > `VT100_STRIP_BORDER_GRAY` since it's now shared by both — one pinned color decision, > 4.4w, not two). Called from `erase_display()`'s box-scoped branch (`mode==2`, TTF mode), > right after the box-clear fill — this covers both the initial clear at > `vt100_enable_ttf()` time and any later `ESC[2J`, so the border survives every box clear, > not just the first, per this item's own done-when. Three-arch QEMU boot + logs clean > (`logs/20260812-150341/amd64`, `logs/20260812-150426/aarch64`, > `logs/20260812-150540/riscv64`); amd64 screendump > (`logs/screendump-4.4z/amd64-border.png`) shows a full rectangle outline around the box, > visually distinct from the strip below it. - [x] **4.4ab — Simplify: the whole framebuffer is one full-screen vt100 terminal; drop the CANVAS box and REPL strip.** Reverted 2026-08-12, Captain Bob's explicit call: "let's just rather simplify things and make the entire framebuffer a vt100 terminal. That'll be much easier." **Supersedes the box/strip mechanism 4.4o/4.4t/4.4x/4.4z built** — those items stay checked as historical record of what was built and why, not edited in place, per this document's own discipline. The 640×480 centered box (4.4o), its text confinement (4.4t), the split into an independent bottom strip plus derived box geometry (4.4x), and the box's drawn border (4.4z) are all removed in favor of the simplest possible shape: `g_vt.cols`/ `g_vt.rows` are `fb_width()/cell_w()` and `fb_height()/cell_h()` — the full screen, no origin offset, no separate strip render path. The REPL prompt is just the terminal's last scrolling line, exactly like any ordinary terminal. **What this keeps, unaffected:** scrollback (4.4q), TTF rendering (4.4j), SGR/ANSI color (4.4h/4.4k), and the keyboard bridge (4.4v, see below) — none of those are box-specific, and all continue to work against the full-screen grid without modification beyond `px_of()`/`py_of()` dropping their now-zero origin offset. *Done when:* three-arch QEMU boot + logs clean; amd64 screendump shows REPL text spanning the full screen width with no box/strip artifacts. *Refs:* §27.8, 4.4o, 4.4t, 4.4x, 4.4z. > **DONE 2026-08-12.** Three-arch QEMU boot + logs clean (`logs/20260812-153337/amd64`, > `logs/20260812-153423/aarch64`, `logs/20260812-153532/riscv64`). amd64 screendump > (`logs/screendump-fullscreen/amd64-fullscreen.png`) — taken after Captain Bob typed > directly into the QEMU window over real emulated PS/2 hardware (`: STAR 42 EMIT ;` / > `: MARGIN 30 SPACES CR ;`, both echoed correctly including shift-key punctuation) — > confirms full-width text with no box confinement, no strip, no border artifacts. - [x] **4.4ac — Scrollback during boot/POST, not just from the REPL onward.** Scoped 2026-08-12, in conversation with Captain Bob. Found while investigating a separate report ("the framebuffer does not mimic the serial port"): `console_putc()` does mirror every byte to both serial and framebuffer during boot, so that report wasn't a mirroring bug — it's that 4.4q's scrollback buffers (`g_shadow`/`g_ring`, `vt100.c:146-150`) are only allocated inside `vt100_enable_ttf()`, sized for the TTF cell grid. Boot/POST runs entirely in `VT_GLYPH_BITMAP` mode against `font_8x16.c`'s fixed 8×16 grid, before that allocation ever happens, so nothing typed to the framebuffer during boot is recoverable once it scrolls off — only the serial log retains it. **Confirmed while scoping: the write path is already mode-agnostic.** `draw_cursor_glyph()` (`vt100.c:278`) and `scroll_up()` (`vt100.c:589`) both gate purely on `g_shadow`/`g_ring != NULL`, with no dependency on which glyph renderer is active. This is not a second recording mechanism to build — it's making the existing one exist earlier. **Decisions locked with Captain Bob 2026-08-12:** 1. **Two independent ring buffers**, not a reflow-into-one. A boot-geometry ring/shadow (8×16 cell stride, allocated in `vt100_init()`, `vt100.c:374`) sits alongside the existing TTF-geometry ring/shadow (unchanged, still allocated in `vt100_enable_ttf()`). Avoids re-wrapping stored boot lines to a different column width at the TTF transition, and leaves the already-working TTF scrollback code untouched. 2. **Boot ring capacity: 4096 lines (guessed, not measured against a target).** Reasoning: tonight's own qemu-esp serial log ran to ~8,500 lines of POST/Hermes-Artemis-self-test/ heartbeat output — 1000 lines (4.4q's current `VT100_SCROLLBACK_MAX_LINES`) would only retain the last ~12% of that. 4096 is a 4x bump, cheap in kmalloc terms (at a typical boot grid this is on the order of 100-200KB), and covers roughly half of tonight's transcript on its own — closing the rest of the gap is expected to come from trimming the actual POST/heartbeat verbosity (a separate, still-open report from the same conversation), not from growing the buffer indefinitely. Revisit this number once that verbosity work happens or is explicitly declined. 3. **One continuous scroll across the boot→REPL seam**, not two separately-triggered histories. `scrollback_line_at()` (`vt100.c:465`) needs to walk boot-ring → boot-shadow → TTF-ring → TTF-shadow, in that order, as one combined index space, so PgUp/scroll-back from the live REPL eventually reaches all the way back through POST without a separate command to switch buffers. **Not decided here, deferred to implementation:** exact kmalloc call sites/error handling for the new boot-mode buffers (mirror 4.4q's existing non-fatal-failure pattern at `vt100.c:444-447`); how `scrollback_redraw()` (`vt100.c:480`) picks which stride/cell size to redraw boot-ring lines at once TTF mode has taken over the screen (likely: redraw at *current* mode's cell size regardless of origin, same "text recovers exactly, appearance doesn't" limitation 4.4q already documents for color). *Done when:* three-arch QEMU boot + logs clean; amd64 screendump after scrolling back from a live REPL session shows POST/self-test text recovered, not just the pre-TTF tail. *Refs:* §27.8, 4.4q. > **DONE 2026-08-12.** Three-arch QEMU boot + logs clean, no regression: > `logs/20260812-173850/amd64`, `logs/20260812-174418/aarch64`, `logs/20260812-175223/riscv64`. > Screendump verification, all three architectures (`logs/screendump-4.4ac/*-scrollback.png`, > `logs/screendump-4.4ac/qemu-{amd64,aarch64,riscv64}.log`), turned out not to need a > physical GTK session as this item originally assumed -- `SCROLL-BACK` is a plain FORTH > word, and `sk_repl_step()`'s input comes from `console_getc()` polling the UART, the same > serial path the old `DOE_INJECT` automation used to drive `EXEC-DOE`. So `99999 > SCROLL-BACK` (clamped to full history) was injected over the serial chardev socket > headlessly on each arch, then captured with QEMU's own HMP `screendump` command over its > monitor socket -- no GUI, no physical typing required, and the same script shape worked > unmodified across amd64 (`-vga std`), aarch64 (`-device ramfb`), and riscv64 (`-device > ramfb`). All three results show `PARITY:MAMA_INIT`/`Init: Mama birth OK` (amd64) or `Init: > Mama birth...` (aarch64/riscv64), plus HADES DoE rows from steps 58-73, all on screen at > once -- genuinely early POST content, not the pre-TTF tail, confirming the boot ring is > real and reachable on every architecture, not just amd64. **Found, not fixed (pre-existing > 4.4q limitation, not introduced here, present identically on all three arches):** the > injected serial text got drawn at the live cursor position even while scrolled back, > visibly corrupting two lines (`okDES][WARN ]`, `ok> S][DOE ]`) -- exactly the "drawing > new live output while `g_scroll_offset > 0` is unverified... would visually corrupt a > scrolled-back view" > case 4.4q's own comment block already calls out as known and out of scope. - [x] **4.5 — URGENT, flagged by Captain Bob 2026-08-11: the kernel build has never used any compiler optimization.** **Blocking priority, set by Captain Bob 2026-08-11: no other punch-list item is worked until 4.5a–4.5f are done**, ahead of whatever would otherwise come next in sequence. Found while investigating why moving `console_fb_init()` earlier in boot (4.4g) stalled boot indefinitely (12,700+ lines logged, still running after 3 minutes vs. a normal few-second boot). Cross-cutting — affects the entire kernel, not just Console work — recorded here because it was found during Phase 4 and blocks 4.4g, but it is its own problem. **Finding:** `Makefile.starkernel`'s `COMMON_CFLAGS` (and every `ARCH_CFLAGS` block — amd64, aarch64, riscv64) specifies no `-O` flag at all, so the kernel and loader compile at GCC/Clang's default `-O0`. This was verified by reading the file directly, not assumed. **Not a regression — checked, not assumed.** Captain Bob's recollection was that `-O2` was already in use; that recollection is correct for the *other* two Makefiles in this ecosystem, just not this one. `git log --all -p -- Makefile.starkernel` (17 commits, full history) shows the only optimization flag ever present in this file is one unrelated host build-tool line (`cc ... -O2`, for `mkcapsule`-adjacent tooling, not the kernel itself), unchanged since it was added. `Makefile.starkernel` itself has had no `-O` flag on the kernel/loader `CFLAGS` since its very first commit, `a5ed8c3` ("Initial commit — LithosAnanke kernel", 2026-08-01) — this was true from day one of this file, not something that regressed later. **Where the impression came from — both siblings do use it:** - `/home/rajames/CLionProjects/StarForth/Makefile` (the standalone StarForth repo): `TARGET_CFLAGS_standard` defaults to `-O2 -flto=auto -fuse-linker-plugin -DNDEBUG`; `fast`/`fastest`/`turbo` targets go to `-O3` plus `-DUSE_ASM_OPT=1`. - This repo's own vendored hosted `Makefile` (root-level, builds the plain hosted `starforth` binary per CLAUDE.md) carries the *same* `TARGET_CFLAGS_standard`/`fast`/ `fastest`/`turbo` ladder — it was copied over intact when the VM was vendored into LithosAnanke. - `Makefile.starkernel` is a separate file, written fresh for the bare-metal kernel target (`a5ed8c3`) rather than derived from either of the above, and the `-O` ladder simply never got carried into it. `include/vm_asm_opt.h`/`vm_asm_opt_arm64.h`/ `vm_asm_opt_riscv64.h` document a hand-optimized-assembly VM path with suggested flags including `-O3`/`-flto` and a `USE_ASM_OPT` macro — but that macro is never referenced anywhere in `Makefile.starkernel` or any `Kconfig*` file, so it was never wired in either; those headers are aspirational documentation for a path that doesn't exist in this build. **Why this isn't a trivial one-line fix — a real, confirmed correctness hazard exists.** At `-O0` every memory access is a genuine load/store with no caching across calls or inlining, so interrupt-shared state "just works" by accident even without `volatile`. Under optimization that accident stops protecting you. A sweep of the ISR-adjacent globals found the codebase is otherwise disciplined about this — `g_sk_fault_word`, `g_spurious_count` (amd64 `interrupts.c:59`), `g_plic_claim_count` (riscv64 `interrupts.c:47`), `g_pending_counter`/`g_pending_valid`/`g_adaptive_period_ns` (`heartbeat.c:57-63`, the documented ISR→mainline top/bottom-half handoff) are all correctly declared `volatile` — but one field was missed: `TimeTrustState.ticks` (`include/starkernel/timer.h:90`, a plain `uint64_t`, not `volatile`) is incremented directly in interrupt context (`heartbeat.c:163`, `heartbeat_tick()`, called from the timer ISR on **all three architectures** — amd64 `interrupts.c:342`, aarch64 `interrupts.c:89`, riscv64 `interrupts.c:80`) and read directly by mainline code via `heartbeat_ticks()` (`heartbeat.c:200-202`), including in a busy-wait loop at `kernel_main.c:880` (`while (heartbeat_ticks() - wait_start < 3 ...)`). At `-O0` this works. Under optimization, if the compiler inlines `heartbeat_ticks()` into that loop, it can legally prove (per the C abstract machine, which does not model asynchronous interrupts) that nothing in the loop body writes `g_heartbeat.ticks`, hoist the read out entirely, and turn that loop into either an instant no-op or an infinite spin — on all three architectures at once, since the pattern is shared. This is a real, specific, isolated bug (one field, not a sprawling unknown), not a hypothetical one — but it is unaudited, and enabling `-O2` before fixing it would ship a live regression. **Not scheduled as one item — broken out below (4.5a–4.5f) per §25.0 rule 1, same treatment as 4.4's own design-only lock before 4.4a onward.** Nothing below is started; this is scoping only, per Captain Bob's explicit instruction 2026-08-11. *Refs:* discovered via 4.4g; independent of Console work. > **Parent closed 2026-08-12.** All six sub-items (4.5a–4.5f) below are `[x]` done; 4.5f's > own done-when is satisfied by 4.4g's real landing, not a separate pass. This checkbox was > never flipped at the time — pure bookkeeping, not new work (found during the FABRIC-2.md > audit). One real loose end surfaced by 4.5f's own text, not closed by any of this: the > ACL-RWT DoE campaign's overhead numbers (CLAUDE.md, "+0.0054%–+0.0088%") were all measured > at `-O0`, before this item enabled real optimization — nobody has re-measured or even > asked whether they're still comparable. Carried forward to FABRIC-2.md. - [x] **4.5a — Full ISR/interrupt-context global-state audit, all three architectures.** Investigation only, no code change. For each architecture, enumerate every vector actually wired to a handler (amd64: IDT/APIC vectors in `arch/amd64/interrupts.c`; aarch64: GIC vectors in `arch/aarch64/interrupts.c`; riscv64: PLIC/trap causes in `arch/riscv64/interrupts.c`), list every global or `static` variable each handler touches either directly or through a called function (e.g. `heartbeat_tick()` → `TimeTrustState.ticks`), and check each one's declaration for `volatile`. The one confirmed hazard (`TimeTrustState.ticks`, `timer.h:90`) came from investigating a single busy-wait loop found by accident while working 4.4g — not from an exhaustive pass — so this item exists precisely because absence of further evidence so far is not evidence of absence. *Done when:* a written inventory exists here (or in a linked scratch doc) covering every handler on all three architectures, with every non-`volatile` ISR-touched global listed by file:line, or an explicit "none found" recorded per architecture. *Refs:* 4.5. > **Done 2026-08-11.** Every vector handler on all three architectures read; every global > or `static` each one touches (directly or through a called function) checked against its > declaration. One confirmed hazard, matching what 4.5 already reported; everything else > checked out, for reasons recorded below rather than left as a bare "it's fine." > > **Confirmed hazard (1):** `TimeTrustState.ticks` (`g_heartbeat.ticks`, `timer.h:90`) — > written directly in the timer ISR on all three architectures (`heartbeat_tick()`, > `heartbeat.c:163`, called from amd64 `interrupts.c:342`, aarch64 `interrupts.c:89`, > riscv64 `interrupts.c:80`) and read directly by mainline via `heartbeat_ticks()` > (`heartbeat.c:200-202`), including the busy-wait at `kernel_main.c:880`. Genuinely > concurrent ISR-write / mainline-read of non-`volatile` state — the real bug 4.6b exists > to fix. > > **Already correctly `volatile` (no action needed):** `g_sk_fault_word` (all three > `interrupts.c`), `g_spurious_count` (amd64 `interrupts.c:59`), `g_plic_claim_count` > (riscv64 `interrupts.c:47`), `g_pending_counter`/`g_pending_valid`/`g_adaptive_period_ns` > (`heartbeat.c:57-63`, the documented top/bottom-half handoff), i8042.c's `ring[]`/ > `ring_head`/`ring_tail`/`g_i8042_isr_count`, virtio_input.c's `g_diag_head`/`g_diag_tail`/ > `g_virtio_input_isr_count`. > > **Not `volatile`, but not a hazard — write-once-during-init, then single-context for the > rest of boot, so no concurrent access ever occurs:** each architecture's timer-calibration > state (amd64 `apic.c`: `timer_initial_count`, `s_apic_hz`; aarch64 `apic.c`: `s_timer_ppi`, > `s_timer_period_tsc`, `s_counter_hz_apic`; riscv64 `apic.c`: `s_timer_period_tsc`, > `s_time_hz`, `s_sbi_time_ok`, `s_next_deadline`) is written once by `apic_timer_init()` > before `arch_enable_interrupts()` is ever called, then touched only by each arch's > ISR-context `*_timer_rearm()` for the rest of boot — verified by grepping every read/write > site of each symbol, not assumed from the pattern looking familiar. Same reasoning for > virtio_input.c's `g_vinput_ready`/`g_virtio_input_plic_source`/`g_virtio_input_gic_intid`: > all three are written exactly once inside `virtio_input_find_keyboard()` (called from > `kernel_main.c` during M7.pre PCI init, before interrupts are enabled) and read-only by > the ISR afterward. > > **Special case, no `volatile` needed by design:** `console_puts()`/`console_println()` > and the framebuffer/VT100 state they touch (`g_vt`, `g_active_vm_name`, `g_line_start`, > `g_fb`) are reachable from ISR context only on the fatal exception/fault path > (`isr_common_handler()`'s default case, `aarch64_exception_handler()`, > `riscv64_exception_handler()`) — and every one of those paths halts the core permanently > afterward (`while(1) arch_halt();` / `for(;;) wfi`/`wfe`). There is no return to mainline > after a fault handler touches console state, so no concurrent-access hazard exists despite > the state not being `volatile`. The hot-path IRQs (timer tick, keyboard, virtio-input) > never call console functions at all. > > No other ISR-reachable global state was found beyond what's listed above, on any of the > three architectures. - [x] **4.5b — Fix every hazard 4.5a found.** Depends on 4.5a. Code fix, no optimization flags touched yet. `TimeTrustState.ticks` is the one already-confirmed instance — mark it `volatile`, or fold it into the existing `g_pending_counter`/`g_pending_valid`-style ISR→mainline handoff pattern already used elsewhere in `heartbeat.c`, whichever fits the finding better once 4.5a's full inventory is in hand. Any further hazards 4.5a surfaces get fixed here too, not deferred. *Done when:* every hazard from 4.5a's inventory is fixed; three-arch boot still clean at the unchanged `-O0` (this item changes correctness under future optimization, not present behavior, so a clean boot here proves no regression was introduced, not that optimization is now safe). *Refs:* 4.5, 4.5a. > **Done 2026-08-11.** 4.5a found exactly one hazard, so this fixes exactly one field: > `TimeTrustState.ticks` (`include/starkernel/timer.h:90`) marked `volatile` in place, > rather than folded into the `g_pending_counter` handoff — `ticks` is a simple monotonic > counter with a single ISR writer and no derived-state computation on the mainline side > (unlike `window`/`variance`/`trust`, which genuinely need `heartbeat_service()`'s > deferred-processing pattern), so a direct `volatile` on the one hazardous field is the > narrower fix and leaves the struct's other fields — which don't need it — unaffected. > Three-arch acceptance boot clean at unchanged `-O0`: amd64 `logs/20260811-100623/`, > aarch64 `logs/20260811-100724/`, riscv64 `logs/20260811-100825/`, all reaching > `[Hera] ok>`. This proves no regression, not that optimization is safe yet — that's > 4.5e's job once 4.5c/4.5d actually turn it on. - [x] **4.5c — Decide and record the target optimization flags.** Design-only, no code. Depends on 4.5b (deciding flags before the known hazard is fixed is premature). Candidates to weigh, not yet chosen: matching the hosted/StarForth ladder's `-O2 -flto=auto -fuse-linker-plugin -DNDEBUG` (this repo's own `Makefile`'s `TARGET_CFLAGS_standard`, and CLAUDE.md already documents that plain `-flto` without `-fuse-linker-plugin` causes "ELF section name out of range" errors on a codebase this size — the same risk applies here if LTO is chosen) versus a more conservative `-O1` or `-Og` for kernel debuggability versus plain `-O2` without LTO as a lower-risk first step. Also decide whether `-DNDEBUG` is appropriate here — check whether the kernel build path actually uses `assert()` anywhere before copying that flag by habit from the hosted ladder. *Done when:* an exact flag set is chosen and written down here with the reasoning, before any Makefile edit. *Refs:* 4.5, CLAUDE.md "Important: Linker Configuration". > **Decided 2026-08-11: `-O2`, no LTO, no `-DNDEBUG`.** > > **`-O2`, not `-O1`/`-Og`.** The kernel has no verified track record at any optimization > level, so this is inherently a first attempt regardless of which level is picked — there's > no "safer" level that avoids needing full re-verification. Given that, pick the level that > actually solves the motivating problem: `-Og` is tuned for debuggability and does not > reliably batch/vectorize loops the way `-O2` does, and the concrete case that started this > (4.4g's `fb_scroll_rows()` stall) specifically needs real loop optimization, not just > "some optimization." `-O2` is also what both sibling Makefiles (this repo's own vendored > hosted `Makefile` and the standalone StarForth repo's) already use as their default > `standard` target — precedented in this codebase, not a novel choice. > > **No LTO (`-flto`/`-fuse-linker-plugin`) yet.** Deliberately deferred, not rejected. LTO is > a second, independent source of risk on top of a first-ever optimization pass, and > CLAUDE.md already documents that this exact codebase has hit "ELF section name out of > range" from plain `-flto` before (hence the hosted ladder's `-fuse-linker-plugin` > workaround). Stacking both changes at once would make any 4.5e boot failure ambiguous > between "-O2 semantics exposed a bug" and "LTO-specific linker issue" — isolate the two. > If `-O2` alone verifies clean on all three architectures, LTO becomes its own > follow-up item, not assumed here. > > **No `-DNDEBUG`.** Checked, not assumed: `grep -rn "assert(" src/starkernel/ > include/starkernel/` turns up exactly two files. `uefi_loader.c`'s two hits are > `_Static_assert` (compile-time, layout checks on `BootInfo` offsets — unaffected by > `NDEBUG` regardless, since `_Static_assert` isn't gated by it). `shim.c`'s one hit is a > comment mentioning "assert()", not a call. There are zero runtime `assert()` call sites > anywhere in the kernel build — `include/starkernel/freestanding/assert.h`'s shim > (`#define assert(expr) ((void)(expr))`, itself unconditional and not gated on `NDEBUG` > either) exists but has nothing to affect. Copying `-DNDEBUG` from the hosted ladder would > be exactly the cargo-culting this item's own text warned against. > > **Chosen flags: `-O2`.** Added to `COMMON_CFLAGS` in 4.5d; no other new flags. - [x] **4.5d — Apply the chosen flags to `Makefile.starkernel`; build all three architectures.** Depends on 4.5b (hazard fixed) and 4.5c (flags decided). Code change: add the flags to `COMMON_CFLAGS` (shared across all three `ARCH_CFLAGS` blocks, so this is one change, not three). Optimization can surface warnings `-O0` never triggers (e.g. `-Wmaybe-uninitialized`) under the existing `-Wall -Werror -Wextra` standard — budget time to fix those, not to weaken the warning set. *Done when:* all three architectures build with zero warnings under the existing `-Wall -Werror -Wextra` standard. *Refs:* 4.5, 4.5c. > **In progress 2026-08-11 — two link-time findings, neither anticipated by 4.5c/4.5d's > own text (which expected new *warnings*, not link failures). amd64 attempted; blocked > before completion; not yet tried on aarch64/riscv64.** > > **Finding 1, fixed:** adding bare `-O2` broke the amd64 link with dozens of > `undefined reference to '__printf_chk'` / `__memset_chk` / `__fread_chk` / `__snprintf_chk` > across `io_words.c`, `system_words.c`, `starforth_words.c`, > `physics_benchmark_words.c`, `physics_pipelining_diagnostic_words.c`. Root cause: these > vendored word_source files unconditionally `#include `/``/ > ``/`` with no `__STARKERNEL__` guard (confirmed by reading > `system_words.c:48-51`); `Makefile.starkernel` has no `-nostdinc`, so these resolve to > real glibc headers, and `src/starkernel/vm/host/shim.c` provides freestanding `printf()`/ > `snprintf()` to satisfy calls into this vendored code. At `-O0`, `__OPTIMIZE__` is > undefined so glibc's default-on-Ubuntu `_FORTIFY_SOURCE` macros stay dormant and the > plain symbol names resolve against `shim.c` — working by accident. At `-O2` those macros > activate and rewrite call sites to the `_chk` variants, which `shim.c` never provided. > **Fixed by adding `-U_FORTIFY_SOURCE` to `COMMON_CFLAGS`** — the standard, precedented > fix for freestanding/kernel builds (Linux and most embedded kernels carry this exact > flag for this exact reason). Verified: all `_chk` link errors gone after adding it. > > **Finding 2, NOT fixed, blocking this item:** with fortification disabled, a second, > unrelated problem surfaced — plain (unfortified) `putc`/`getc` are genuinely undefined > symbols. `shim.c` only backfills `printf`/`snprintf`, never `putc`/`getc`. Call sites: > `profiler.c` (`profiler_print_hotspots`/`profiler_generate_report`), `io_words.c` > (`io_word_key`), `system_words.c` (`system_word_words`/`system_word_vlist`). These same > call sites do **not** break the `-O0` link — GCC's `-O2` function-splitting pass visibly > clones some of them (`profiler_print_hotspots.part.0` in the linker error) and something > about that changes which code the linker ends up pulling in; the exact mechanism by which > `-O0` avoids needing these symbols is not established, only that it reproducibly does. > Captain Bob's call 2026-08-11: **stop here, don't fix in-place** — this needs a real > design decision (backfill freestanding `putc`/`getc` in `shim.c` matching the existing > `printf`/`snprintf` pattern, versus guarding these call sites out of `__STARKERNEL__` > builds entirely) rather than a mechanical flag. **`Makefile.starkernel` reverted to the > committed `-O0` state** (the change was uncommitted, so a plain `git restore` — nothing > broken is in the tree or history). 4.5d cannot complete until this is resolved. > > **Finding 2 fixed 2026-08-11:** backfilled `putc()`/`getc()` in `shim.c` as thin wrappers > reusing the existing `putchar()`/`getchar()` implementations exactly (`putc` → > `console_putc` via `putchar`; `getc` → the existing "no stdin in kernel" `-1`/EOF stub via > `getchar`) — Captain Bob's chosen approach, not new behavior. Verified: with both > Finding 1 (`-U_FORTIFY_SOURCE`) and Finding 2 fixed, amd64 links clean at `-O2` with zero > *new* warnings — rigorously confirmed by diffing normalized warning text between an `-O0` > and an `-O2` build (`diff` empty; all ~3040 pre-existing warning lines, in vendored > VM-core code already downgraded from `-Werror` by `VMCORE_CFLAGS_COMMON`, are byte-for-byte > identical between the two, not introduced by this work). > > **Finding 3, NOT fixed, blocking this item: amd64 boot stalls for minutes inside PM > Timer TSC calibration at `-O2`**, with Findings 1 and 2 both fixed. Serial log stops dead > at `Timer: CPUID frequency unavailable; trying PM Timer...` — everything before that point > (console, PMM, VMM, IDT, APIC, I/O APIC, i8042) comes up identically to the `-O0` boot. > This is `calibrate_tsc_with_pmtimer()` (`arch/amd64/timer.c:560-589`), a mainline-only > (pre-interrupt-enable) busy-wait loop reading the ACPI PM Timer port via `inl()` > (genuinely `volatile` inline asm, confirmed not compiler-eliminable) until it observes > 1000 real PM-timer ticks elapse, bounded by a 5,000,000-iteration hard timeout. Added a > one-shot diagnostic probe (per this document's established write/run-once/capture/revert > discipline) printing `iters`/`start`/`cur`/`delta` every `0x100000` iterations: exactly > one line fired (`iters=0 start=9245289 cur=9245344 delta=55`) in a 55-second bounded > observation window, then nothing — the loop never reached its next million-iteration > checkpoint. Not yet root-caused: this is consistent with either the 5,000,000-iteration > timeout itself taking several real minutes to exhaust (implying each iteration got far > more expensive under `-O2`, mechanism unknown) or a genuine non-terminating condition; > the evidence gathered doesn't yet distinguish the two. Probe reverted after capture, not > left in the tree. `Makefile.starkernel` reverted to `-O0` again; nothing broken landed. > > **Follow-up probing, 2026-08-11.** A second, finer-grained one-shot probe (checkpoint > every 1000 iterations instead of `0x100000`) caught one full run: `iters=0 delta=67`, then > `iters=1000 delta=951` (~462 TSC cycles/iteration, unremarkable) — genuinely close to the > `target_ticks=1000` exit condition — then nothing for the rest of a 40s window; the loop > never reached `iters=2000`. A third run with per-iteration granularity around the > 900–1100 range caught **only** `iters=0` before a **400-second (6.7 minute)** bounded wait > expired — worse than the first two runs, and inconsistent run-to-run in exactly how far it > gets, which itself is informative: this isn't a fixed, deterministic slowdown factor. > > **Hypothesis tested and disproven:** single-threaded TCG's cooperative scheduling being > starved by an -O2-tightened loop body — meaning the emulated PM Timer's own host-side > update never gets a chance to run, so `cur` stops advancing from the guest's perspective — > seemed well-supported by a concrete precedent: `calibrate_apic_timer()` in `apic.c:382-406` > (a sibling calibration loop, same file family, same TCG target) already calls > `arch_relax()` (x86 `PAUSE`, `include/starkernel/arch.h:76`, "architecture-friendly > pause/yield hint inside busy loops") on every iteration of its own spin-wait, while > `calibrate_tsc_with_pmtimer()` never had it. Added `#include "arch.h"` and one > `arch_relax();` call per loop iteration, matching that precedent exactly. **Result: no > change.** Same exact stall point (`Timer: CPUID frequency unavailable; trying PM > Timer...`, then nothing), confirmed with a fresh 60-second bounded wait. Reverted (both > the `#include` and the `arch_relax()` call, plus `Makefile.starkernel` back to `-O0`); > amd64 boots clean again at `-O0`, confirmed. > > **Where this leaves it:** the starvation hypothesis isn't confirmed wrong, exactly — `x86 > PAUSE` is documented to matter most for VM-exit-based virtualization (KVM), and this > project's entire acceptance methodology is TCG-only (software emulation, no VM-exits to > hand control to a scheduler); `PAUSE` alone may simply not be the right primitive for > whatever TCG-specific mechanism is actually at play here, if that theory holds at all. No > alternative hypothesis has been tested. This is squarely a "stop and report" point per > this document's own §25.0 rule 5 — root-causing further needs either deeper TCG/QEMU > knowledge than has been brought to bear so far, or a fundamentally different diagnostic > approach (e.g., instrumenting on the QEMU/host side rather than guest-side probes) that's > a bigger step than another guess-and-check pass. Notable stakes: per CLAUDE.md, QEMU/TCG > is not *a* target for this project, it's *the* acceptance target — this bug blocks the > actual thing that matters, not an edge case. > > **Root-caused and fixed, 2026-08-11 — it was never a hang.** Used QEMU-side > instrumentation as Captain Bob directed: `-d int` execution-exception tracing plus a > chardev-based monitor socket (the older bareword `-monitor unix:...,server,nowait` syntax > silently failed to create a socket on QEMU 10.2.1; the modern > `-chardev socket,...` + `-mon chardev=...` form works). The trace showed the CPU never > looping at all: a genuine **`#DE` (divide error) at the second `muldiv64()` call site**, > cascading through a double fault into a **triple fault** — which `-no-reboot` converts > into a silent, clean QEMU exit (`exit code 0`, empty stdout), indistinguishable from an > infinite hang from the *guest* side without tracing. This is also why the earlier > `arch_relax()` attempt did nothing: it was solving a hang that didn't exist. > > Register state at the fault: `RAX=0xe8d4a51000` — exactly `1000 × 1,000,000,000`, > confirming the values feeding `muldiv64(elapsed_ticks, 1000000000ull, PMTIMER_FREQ_HZ)` > (`timer.c:597`, `elapsed_ticks` at loop exit == `target_ticks` == 1000). That product fits > entirely in the low 64 bits, so `mulq`'s high-word output (`RDX`) is 0. `muldiv64()`'s > inline asm declared `RDX` as a plain output (`"=d"(hi)`) — telling GCC only "I want to > read RDX's value after this block," with nothing indicating that `mulq` writes RDX > *before* `divq` needs a *different* value (the divisor `c`) out of it. Nothing stopped the > register allocator from placing `c` itself in `RDX`, which `mulq` then overwrote with 0 > before `divq` ever read it — dividing by a corrupted 0 instead of the intended > `PMTIMER_FREQ_HZ` (3,579,545). Worked by accident at `-O0`'s more conservative > allocation; `-O2` actually hit it. > > A `unsigned __int128` rewrite was tried first (cleanest fix in principle) but needs > libgcc's `__udivti3` for the general 128÷64 case, undefined in this freestanding, > `-nostdlib` build — not viable, the same class of problem as the `putc`/`getc` finding > earlier in this item. Fixed instead by declaring `rdx` a pure **clobber** rather than an > output — the same pattern the Linux kernel's own `mul_u64_u64_div_u64` uses. A clobber > tells GCC the register is used internally for the whole asm block and must never be > allocated to any operand, which is exactly the guarantee the previous constraint list was > missing. Also added a defensive `end_tsc < start_tsc` guard in the caller — this file's > own comments already flag TSC non-monotonicity as a real risk under TCG hypervisor mode, > and an underflowed `delta_tsc` would hit the same class of quotient-overflow `#DE`; not > the bug that was actually found, but a real latent risk given what this function's own > documentation already says about the environment. Fix committed (`b43e51a`), three-arch > acceptance boot clean at unchanged `-O0`. > > **Verified this specific stall is gone:** with `-O2` re-enabled (uncommitted), amd64 boot > now proceeds far past this point — through capsule birth, Mama birth, ACL pinning, and > into Hermes's word registration — before hitting a **second, different, not-yet-fixed** > fault (below). Given that, `-O2` was **not** left enabled; reverted to `-O0` again. > > **Finding 4, NOT fixed, blocking this item: a second, distinct fault during Hermes word > registration at `-O2`.** Same tracing technique, same deterministic reproduction (stuck at > the exact same serial line, `Registering FORTH-79 arithmetic words...`, across repeated > runs). Different signature this time: `check_exception old: 0xffffffff new 0xd` — a > **`#GP` (General Protection, vector `0xd`) directly**, not a `#DE`, with error code > `0x102`. Decoded per the x86-64 selector error-code format (bit 0 = external, bit 1 = IDT > table indicator, bit 2 = TI, bits 3–15 = selector index): `IDT=1`, index = `0x102 >> 3` = > **32** — exactly `APIC_TIMER_VECTOR`. This fires during otherwise-unrelated Hermes > word-registration work, which is consistent with the periodic 100 Hz timer interrupt (that > fires continuously in the background regardless of what else is running) hitting a > problem with its own IDT descriptor — not yet traced to a specific cause the way Finding 3 > was; no register-state correlation to a specific call site has been done yet for this one. > Same cascade shape as before (`#GP` → `#GP` → double fault → triple fault → silent QEMU > exit), so this was very likely mis-diagnosed as "part of the same hang" before tracing > distinguished the two. `-O2` reverted; `muldiv64()`'s fix is kept (real, independently > verified). > > **Follow-up, 2026-08-11 — deeper tracing, not yet root-caused.** Computed the loader's > true runtime-vs-link-time relocation delta (printed `&arch_interrupts_init` at runtime, > diffed against its `nm` link-time address: delta `0x3d5b9000`) to correlate fault > addresses against the actual binary — necessary because the running image is > `starkernel_loader.efi` (a PE, `MONOLITHIC_BUILD`-embedded, genuinely relocated at UEFI > load time), not the separately-linked `starkernel_kernel.elf` first assumed; that > separate `starkernel_loader.elf` debug-symbol target (`Makefile.starkernel:688`) fails to > link (`R_X86_64_32S` relocation error) and isn't part of `all` — a pre-existing gap, not > touched. > > With the delta in hand, the fault RIP (`0x3d5cc0e0`/`0x3d5cc0e0`-ish across runs) decodes > to link address `0x130e0` — the very entry of `log_message()` (`vm/host/shim.c`). This is > almost certainly coincidental, not causal: `log_message()` is called on essentially every > HADES/ECW dispatch during word registration, so an async 100 Hz timer tick landing exactly > at its entry is unsurprising and doesn't implicate the function itself. > > Used QEMU's monitor to enable `-d exec,int` execution+exception tracing (the earlier > `-d int`-only session had already established the fault; this pass wanted the > instruction-by-instruction lead-up). Two attempts to start tracing late (right before the > danger zone, via the monitor's `stop`/`log`/`cont` sequence, to keep the trace small) > **both failed** — QEMU reached the crash and exited before the host-side script could > connect, meaning the window between a detectable serial checkpoint and the actual fault is > shorter than the host's reaction latency even under `-S` synchronization races. Fell back > to tracing from cold boot with `-S` + immediate `log exec,int` before any `cont` (reliable, > but expensive: ~2.7 GB / ~39M lines per attempt; both trace files deleted after use, not > committed). > > That full trace shows the CPU executing a small, tight, **three-block repeating loop** > (`0x3d5bd120` → `0x3d5bcad9` → `0x3d5bcd05`) many times immediately before the fault — a > completely unremarkable pattern consistent with normal per-word dictionary-registration > work, not a wild jump in progress. Immediately after: `Stopped execution of TB chain`, > `Servicing hardware INT=0x20` (`APIC_TIMER_VECTOR`), and then the `#GP` — with `IDT=` already > showing `limit=0` at that exact instant. Re-examined the error code (`e=0102`) in this > light: `IDT=1` (bit 1), index `0x102>>3=32` — consistent with *either* "descriptor 32 > specifically is bad" *or* "the table's limit is exceeded for any index," and an > x86-64 IDT-limit violation reports the attempted vector number in the error code either > way, so this doesn't distinguish between "one corrupted descriptor" and "the whole table > became limit=0" — both remain live explanations. > > **Ruled out:** a second, illegitimate `lidt` call — confirmed only one call site in the > entire codebase (`interrupts.c:476`, one-time M4 boot setup), and searched the exec trace > for any *later* execution of `arch_interrupts_init()`'s address range — found none; the > only occurrences are the single legitimate boot-time cluster. > **Not yet established:** the actual corrupting write/instruction. The repeating loop > immediately preceding the fault looks unremarkable in the trace, which means either (a) > the corruption happened earlier still and IDTR/the IDT table sat silently wrong for a > while before the next timer tick exposed it (most likely, given nothing in the visible > lead-up looks like a wild jump), or (b) it's a genuine TCG emulation artifact rather than a > guest-code bug at all (not tested — would need a different accelerator or QEMU version to > rule in/out, and this project's methodology is TCG-only by design). > > **Where this leaves it:** pinpointing the exact corrupting instruction from here needs > either GDB-level single-stepping (QEMU's `-s -S` gdb stub + a matching cross-gdb, watching > the `idt[]` array and IDTR directly across the whole pre-fault window) or a fundamentally > different narrowing strategy — a bigger tooling step than the tracing done so far, and one > this session did not attempt. 4.5d cannot complete until Finding 4 is resolved. > > **Finding 4 root-caused, 2026-08-11 — not corruption, mis-construction; static analysis, > no GDB session needed.** Before single-stepping, checked whether `idt[]` had a suspicious > `-O2`-layout neighbor (per Findings 1–3's pattern) via `nm`/`readelf` on a fresh `-O2` > build — and found something more direct: `arch_interrupts_init()` itself, disassembled > from the `-O2` object file, is only 30 bytes (`lidt` off an uninitialized 6-byte stack > slot → mask both PICs → `ret`). The entire 256-entry `idt[]`-population loop > (`set_idt_entry()` calls, `interrupts.c:461-468`) and the `idtr_desc.limit`/`.base` > assignments (`interrupts.c:470-472`) are gone from the compiled output — dead-store > eliminated. Root cause: `lidt()`'s inline asm (`interrupts.c:163-165`, > `__asm__ volatile ("lidt (%0)" :: "r"(idtr_desc))`) uses an `"r"` (register) constraint, > telling GCC only that the asm reads the *pointer value* — nothing tells it the asm > dereferences the pointee, so nothing anchors the writes to `idt[]` or `idtr_desc`'s fields > as observed. `volatile` blocks reordering/removal of the asm statement itself but does > nothing for dead stores feeding into it. At `-O0` this is invisible (nothing is eliminated > without optimization); at `-O2` GCC removes the whole population loop as unobservable, > and IDTR gets loaded with stack garbage instead of the real table — explaining both the > `#GP` on the first APIC timer tick that happened to hit a garbage descriptor slot, and the > earlier `limit=0`-flavored trace evidence. Same bug class and same fix shape as Finding 3 > (`muldiv64`): an inline-asm operand constraint too weak for what the asm actually touches. > Precedented fix (this is exactly how Linux's own `load_idt()` is written): change the > constraint to `"m"(*idtr_desc)` so GCC knows the asm dereferences the struct. **Not > applied — reporting only, per Captain Bob's Law.** `-O2`/`-U_FORTIFY_SOURCE` build used > only to produce the diagnostic object file, then reverted; tree confirmed clean, back at > committed `-O0`. 4.5d still cannot complete until this fix is written and verified. > > **Fix applied and verified, 2026-08-11 — all three architectures.** `lidt()` > (`interrupts.c:163-165`) changed to `__asm__ volatile ("lidt %0" :: "m"(*idtr_desc));`. > amd64: disassembly of `arch_interrupts_init()` at `-O2` now shows the full (GCC-vectorized) > 256-entry population loop instead of the 30-byte stub; full acceptance boot reaches `ok>` > clean, past the prior Hermes-registration fault point, POST `Failed: 0`, Mama/Hermes parity > and dict-hash records present. Warning diff against a fresh `-O0` baseline: byte-for-byte > identical (3040 warnings both sides, zero new). > > **Checked aarch64/riscv64 for the same bug class first** (before building/booting): neither > has an equivalent. Both install their vector/trap table entirely in hand-written `.S` > (`isr.S`: `msr vbar_el1, x0` on aarch64, `csrw stvec, t0` on riscv64), not via a C loop > populating an array that a separate C function then hands to inline asm through a > pointer-only constraint. Their C-side inline asm is limited to scalar CSR/system-register > reads (`mrs`/`csrr`) with correctly-matched `"=r"` constraints — not the same shape as the > `lidt` bug, nothing to fix. > > **Both then built and booted clean at `-O2` anyway**, no changes needed: aarch64 and > riscv64 both reach `ok>`, POST `Failed: 0`, and — notably — identical dict-hashes to the > amd64 `-O2` run (`0x211a35043331d472` Mama / `0x97502db38aec4d04` Hermes), confirming > cross-arch parity holds under optimization, not just per-arch self-consistency. Warning > diffs: riscv64 identical to `-O0` (3037/3037) on first pass. aarch64's first pass showed a > spurious +289-warning discrepancy in `-Wmissing-field-initializers` counts (already a > documented, somewhat unstable warning class per this file's Code Standards section) that > turned out to be parallel-make (`-j`) stderr interleaving corrupting the log, not a real > `-O2` regression — confirmed by rerunning both `-O0` and `-O2` serially (`-j1`): identical > 3041/3041, zero new warnings. > > **Landed, 2026-08-11.** Captain Bob approved: `-O2`/`-U_FORTIFY_SOURCE` applied to > `COMMON_CFLAGS` in `Makefile.starkernel` permanently (not reverted this time), fix and > this writeup committed together. - [x] **4.5e — Three-arch acceptance boot with optimization enabled.** Depends on 4.5d. This is the actual gate, per CLAUDE.md's non-negotiable acceptance criteria — a kernel that has only ever been built and accepted at `-O0` has no track record at any other optimization level, so this is a full fresh verification, not a formality. *Done when:* all three architectures boot clean to `ok>`/`zuse)ok>`, POST's dictionary-hash parity check still passes, logs captured in `logs/` per CLAUDE.md. *Refs:* 4.5, CLAUDE.md acceptance criteria. > **Done, 2026-08-11.** All three architectures verified in the same session that landed the > fix (see 4.5d's log): amd64 (`logs/20260811-164545/amd64/`), aarch64 > (`logs/20260811-164924/aarch64/`), riscv64 (`logs/20260811-165027/riscv64/`) — all reach > `ok>`, POST `Failed: 0`, dict-hashes identical across all three > (`0x211a35043331d472` Mama / `0x97502db38aec4d04` Hermes), confirming cross-arch parity > holds under `-O2`. - [x] **4.5f — Retry the 4.4g `console_fb_init()` reorder now that optimization is live.** Depends on 4.5e. This is the original motivating case: both attempts this session (bare, and with the `fb_scroll_rows()` volatile fix alone) stalled boot indefinitely at `-O0`. *Done when:* the reorder from 4.4g completes in a reasonable time (no multi-minute stall) on a three-arch boot, and an amd64 screendump shows the fuller boot transcript that was 4.4g's whole point. *Refs:* 4.4g, 4.5. > **Aside, not a task:** the ACL-RWT DoE campaign's overhead numbers (CLAUDE.md, "measured > overhead +0.0054%–+0.0088%") were all measured at `-O0`. Nobody has asked whether those > numbers still hold, or even remain comparable, once the kernel is built at a different > optimization level. Not scoped here — flagging so it isn't lost if 4.5 ever lands. > **Experiment run, 2026-08-11 — uncommitted, reverted after capture, per Captain Bob's > explicit instruction this was informational only (4.4g's reorder decision is still open, > not made by this experiment).** `console_fb_init()`'s call site moved in `kernel_main.c` > from after `capsule_birth_mama()` to just before it (same code, no logic changes). amd64, > `-O2`: boot completed cleanly, reached `ok>` in well under the bounded 300s test window > (previously stalled indefinitely at `-O0` — 12,700+ lines, still running after 3 minutes). > Real cost, not zero: 2785 heartbeat ticks at 100 Hz vs. ~176–231 ticks on the > non-reordered `-O2` boot from 4.5d/4.5e — the framebuffer-visible fleet-birth/self-test > transcript adds substantial real time (screen-draw + scroll volume), it just no longer > hangs. Screendump confirms the actual point: the framebuffer now shows the full > HADES/ECW/Stadium/self-test transcript, not just the small post-birth tail — > `evidence/amd64/qemu-screenshot-20260811-170329-4.5f-reorder-experiment.png`. Captured via > a QEMU HMP monitor socket + `screendump` (the `qemu` Makefile target runs `-display none` > with no monitor by default; this run added `-chardev socket,id=mon0,... -mon > chardev=mon0,mode=readline` to get one). `Makefile.starkernel`'s amd64 `qemu` target > deadline was temporarily dropped from 43200s to 300s for this one test run, then reverted; > `kernel_main.c`'s reorder was reverted immediately after the screendump was captured. Not > run on aarch64/riscv64 — this was a single-architecture feasibility check, not the item's > own three-arch acceptance pass, which only applies once the reorder is actually decided > and landed. > > **What this answers and doesn't:** confirms the stall was specifically an `-O0` cost > problem, not a correctness bug in the reorder itself — `-O2` alone is sufficient to make > it viable. Does **not** decide 4.4g's open question (whether the reorder is wanted); it > only removes "it hangs" as a reason not to. The real-cost number above (≈12× more > heartbeat ticks) is new information for that decision and wasn't available before this > run. > > **4.4g decided "yes," reorder landed for real, 2026-08-11 — see 4.4g's own record for the > full three-arch verification and final screendump.** This item's own done-when (three-arch > boot with no stall, amd64 screendump of the fuller transcript) is now satisfied by that > same landing, not a separate pass. - [ ] **4.6 — Artemis last.** It works today; it is the thing that cannot be broken. *Refs:* §10. --- ## 25.6 Phase 5 — Verification and measurement - [ ] **5.1 — Re-run the DoE on the new substrate.** A green POST suite is not evidence that K holds; those are different claims. *Refs:* §10. - [ ] **5.2 — Isabelle/HOL.** One datatype, one index space, one conservation theorem. *Refs:* §13, §22.3. - [ ] **5.3 — Shrink the subsystem documents.** `ARTEMIS.md`, `HERMES.md`, `CONSOLE.md`, `TRIPOD.md` should each reduce to roughly three lines. Any that grows is fighting the design. `TRIPOD.md` also needs its Immediate Goal rewritten — it currently requires Hera to spawn Hermes and Artemis at boot, which 0.1 undoes. *Refs:* §11. --- ## 25.7 Reported, not scheduled *Found while reading. Not fixed, not assigned. They become items only if Captain Bob says so.* - **Fleet heat leaks on every multi-VM touch.** `vm_physics_touch()` fans out `(moved_total * heat) / others_total` per VM in integer arithmetic (`capsule_vm_physics.c:304-305`); the shares sum to less than `moved_total`, so total fleet heat drifts downward monotonically. `VM_PHYSICS_EPSILON_Q48` is 5% of `Q48_ONE`, so a long enough run would trip `VM-CONSERVED?`. Nobody has measured the rate. This is a live defect in a conservation law the project makes claims about — see §20.2. > **Note to self, flagged by Captain Bob 2026-08-04, before item 5.1.** Currently > invisible: with Tripod pruned to Hera alone (item 0.1), `others_total` is always 0, so > this path is never exercised — nothing today can trip it. It becomes reachable, and > therefore measurable, the moment Phase 4 restores Hermes/Artemis. Check this before > trusting item 5.1's DoE re-run as evidence that `VM-CONSERVED?` holds: a clean run on a > document this careful about falsifiability elsewhere, sitting on top of an unmeasured, > monotonic leak, would be a false negative, not a green light. Still reported-not- > scheduled on purpose — becomes its own item only if Captain Bob says so. - `hotwords_cache_promote()` writes NULL into the ring if `word` is NULL and the cache is full (`physics_hotwords_cache.c:363-364`). Unreachable today. - `heartbeat_trust()` is exported and has zero callers. - `m5_time_trust` and `m5_variance` (`include/vm.h:315-316`) are declared and never used. - `src/*.c.bak` files are tracked in git at the `src/` top level. - The `bump-z` / `bump-y` targets in the hosted `Makefile` reference version macros that do not exist in the generated `include/version.h`. - **Kconfig/`menuconfig` has never been exercised end-to-end.** Every Kconfig knob added so far, including item 4.1's `STADIUM_WORD_HEAT_QUANTUM`/`STADIUM_WORD_COOL_RATE_Q48`, has only ever been verified via its `Makefile.starkernel` `kconfig_int`/`kconfig_bool` default. Nobody has run `make -f Makefile.starkernel menuconfig`, changed a value, and confirmed it actually flows through to a build. Flagged by Captain Bob 2026-08-05. - ~~**`stadium_admit()` never writes `stadium_owner[idx]`**~~, on either the free-list-pop or the eviction-fallback path (found during item 4.1's design pass, 2026-08-05). **RESOLVED — folded into item 4.2's scope by explicit Captain Bob ruling**, fixed and verified: a resident cell's evict-credit lands in the correct VM's reservoir with two VMs holding quotas (Hera + Hermes), not just asserted from reading the code. This bullet was never struck through at the time; stale, not new work (found during the 2026-08-12 FABRIC-2.md audit). See item 4.2's *Done when* list and its 2026-08-05 addendum. - **Taxonomy and lexicon.** Raised by Captain Bob 2026-08-04, mid-item-3.1. This document's physics-flavored vocabulary (heat, mass, density, patron, Stadium, and the rest) needs a glossary that is explicit these are named analogies, not physical claims — and that also covers the growing set of Kconfig build knobs (`STADIUM_MAX_VM_COUNT` and its siblings) so the terminology in code, Kconfig help text, and this document stays one language instead of drifting apart. Not scoped, not placed in a phase. Captain Bob: "I guess that we didn't finish out FABRIC.md quite as much as we thought." ### 25.7.1 Second review pass — 2026-08-03, pre-coding. Awaiting rulings. *A full re-read of this document as it stood after the first review's corrections, looking for what would break a lower-capability model working the punch list.* **Status: all fourteen findings closed, 2026-08-03.** A1 ruled (virtual tick) and applied to §16.4/§17.1/§18.4 and items 0.8/2.1. B1 verified (no DTB access; fixed into 0.3/0.6), B2 verified (no FP restriction on any arch; fixed into 0.2/0.5), B3 applied (EL governs the vector path; 0.4/0.5). C1–C7 applied to their items; D1–D3 swept. The findings below are preserved as the record of what was found and why. #### GAP-A1 — §16.4's central inference is unsound. ~~NEEDS RULING~~ **RULED 2026-08-03: virtual tick.** > **Applied.** The recommended resolution below was adopted by Captain Bob. §16.4, §17.1 > and §18.4 now carry the ruling; items 0.8 and 2.1 were reworded to it. Item 0.10 needed > no change: with the engine staying execution-paced, its double-boot dict-hash check is a > valid regression guard and the amd64-as-control framing is accurate again, since 0.8 no > longer touches engine plumbing on any architecture. The argument below is preserved as > the record of why. §16.4 claims: *"same input → same tick ordinal → same reap and inference events → same hash."* The last arrow is invalid. The hash covers `execution_heat`, which is co-written by **two streams** — word executions (increments) and engine ticks (decay). Once ticks come from a hardware timer, *where tick N lands relative to the instruction stream* is wall-clock-dependent: under TCG, run A takes tick 42 after word #1000, run B after word #1017. Decay interleaves differently, heat trajectories diverge, hashes differ. Firing on tick count fixes the engine's *schedule*; the hash measures the *composition* of the two streams, and that is not fixed. Blast radius: - **Item 0.8 is ambiguous between two different kernels.** Reading (i): the bottom half services only TIME-TRUST bookkeeping — safe, parity holds, but "compudynamics on the tick" did not actually happen. Reading (ii): the bottom half drives the engine/decay from the hardware tick — parity breaks *by construction*, not by implementation error. - **Item 0.10's double-boot dict-hash check** then fails under reading (ii), and no implementation effort can fix it. - **Item 2.1's corrected acceptance** (identical fleet heat *sum* across runs) is *still* unachievable under a hardware tick: touch amounts scale with elapsed ticks between fixed execution points, elapsed ticks vary run to run, so truncation losses vary, so the sum varies. The first review's amendment did not go far enough. - **The tempting split does not survive either.** "Hash-covered state on execution ticks, TTLs on hardware ticks" fails because TTL expiry has side effects on the instruction stream — a message expiring versus being delivered changes what runs, which corrupts heat downstream. §17.1's "one tick" instinct was right; it picked the wrong clock. **Recommended resolution (not decided):** the engine's tick is a **virtual tick — a pure function of the execution stream**, which is exactly what exists today and why parity holds today. The hardware heartbeat becomes: the TIME-TRUST instrument (now real on three ISAs instead of one), the idle wake source, and the driver of nothing that feeds patron state. Scripted/parity runs stay bit-identical; interactive idling pumps virtual ticks from the REPL poll loop so TTLs still expire in real time, in a context where parity was never claimed. Phase 0's timer work remains fully justified as instrument and substrate. Under this ruling §16.4, §17.1, §18.4 and items 0.8, 0.10, 2.1 all need rewording. The alternative — re-baselining the parity claim itself — touches the patent support material and is not recommended. #### GAP-B — unverified prerequisites (each is a short read; none has been done) - **B1 — Device tree reachability.** Items 0.3 and 0.6 instruct "read from the device tree" (0.6 forbids alternatives). Whether the loader captures the DTB from the EFI configuration table into `BootInfo` is unverified. If it does not, 0.3 and 0.6 silently require loader plumbing that has no punch item. Read `uefi_loader.c` / `BootInfo` first. - **B2 — FP/SIMD in the ISR path.** Items 0.2 and 0.5 save integer state only. If the kernel is not built with `-mgeneral-regs-only` (aarch64) / soft-float (riscv64), a C interrupt handler may clobber FP registers the interrupted mainline was using. One grep of `Makefile.starkernel` settles it; the items should carry the check. - **B3 — 0.4's EL detection does not govern the vector path.** 0.4 refuses to hardcode the EL for timer registers, but 0.5 hardcodes `ELR_EL1`/`SPSR_EL1`/`eret`, and today's `isr.S` installs `VBAR_EL1`. If EDK2 leaves the kernel at EL2, exceptions vector through `VBAR_EL2` and 0.5's entire edit targets a table that is never consulted. EL determination must govern VBAR, the saved-state register forms, *and* the timer set. #### GAP-C — defects in punch items a literal implementer will hit - **C1 — Item 0.1 contradicts itself.** Body says remove "blocks 2050–2059"; Refs says block 2050 *survives*. The delete set is 2051–2056 + 2058–2059; 2050 is edited (banner call kept, handshake/broadcast calls removed). A literal reading deletes the banner. - **C2 — Items 0.2 and 0.5 have unsatisfiable acceptance.** Both require having "taken and returned from at least one trap/IRQ," but at 0.2 no timer is armed (0.3) and at 0.5 there is no GIC (0.6) and no armed timer (0.7). No interrupt source exists at those stages. Fix: 0.2/0.5 accept on "boots unchanged, no regression"; the took-and-returned evidence moves to 0.3/0.7. - **C3 — Item 0.3 lost the two silent-failure modes.** The SBI timer is one-shot: a missed re-arm stops the heartbeat forever with no error. `sie.STIE` is also unmentioned. 0.7 says "re-armed each tick"; 0.3 must too. - **C4 — §23.4 #4 blocks item 3.1 but is not a punch item.** The continuation-cell encoding gates 3.1 by 3.1's own text, but rule 1 walks numbered items and nothing ever schedules it. It should become item 1.12. - **C5 — Item 1.11's deferral is not a formal prerequisite.** It says "do not settle speculatively" but states no blocker, so rule 1 would schedule it. Add "(blocked on 4.3)." - **C6 — Item 0.10 misc.** "amd64 output unchanged" treats amd64 as a control, but 0.8 changes amd64's engine plumbing by design — stale framing. "TIME-TRUST and variance sane" is soft; sharpen to trust near `Q48_ONE`, variance small relative to the new `expected_delta`. - **C7 — The commit template hardcodes "Claude Opus 5."** Whichever model implements will either violate the template or misattribute. Genericize. #### GAP-D — inconsistencies left by the layered amendments - **D1 — Three passages still argue from the K-justification the first review removed.** §17.3 ("wastes the bounded capacity that gives K a fixed denominator"), §17.5's sizing argument (same phrase), and §17.6(d) — the worst, since it cites §2 for a claim §2 now explicitly disavows ("Without an inescapable bound, K is bookkeeping — §2 says this in as many words"). - **D2 — §19.6 #1 and #2 read as open but are resolved** (#1 by §23.1 with the residue in §23.4 #4; #2 by §24.3). §17.4 got strike-through treatment; §19.6 did not. - **D3 — §20.3 still says "LEANING nested"** one section before §21 decides it. One forward pointer fixes it. #### What held up under this pass The patron taxonomy, behaviours-not-kinds dispatch, the three quantities, the two-valued cell union, the nested-elastic-quota layout, the identity/mass invariant, Hera's pin-and-panic, and §25.0's rules themselves. None of them moved. **Triage order when this is picked up:** rule on A1 first — it decides what item 0.8 even means. B1/B2 are ten-minute reads. C and D are mechanical once A1 is ruled. Nothing should go to a coding model before C1, C2 and C3 are fixed at minimum — those are the ones it will hit in its first hour. --- ## 26. The hardware heartbeat must itself be adaptive — RULED 2026-08-03 Raised mid-0.8: the punch list, as written, makes the hardware tick a fixed-rate instrument (100 Hz, unconditionally, on all three architectures — the `apic_timer_init(..., 100)` calls built across items 0.1–0.7). That is correct for what §18.4/§18.5 require of the *engine* — the virtual tick stays execution-paced regardless. But it leaves the *physical* heartbeat monotonic, and the physical heartbeat was never supposed to be monotonic. Captain Bob: *"the heartbeat is adaptive... it spreads the heartbeat out when your heart goes faster when you're running... it's gotta be an adaptive heartbeat, that's the whole thing to cage variance."* ### 26.1 The finding — an adaptive-rate engine already exists, and it is orphaned `vm_runtime.c:703-752` ("Loop #7 — Adaptive Heartrate") computes a bounded adaptive period, `vm->heartbeat.tick_target_ns`, from the same ANOVA-driven stability signal Loop #5 already uses: early-exit (stable) slows it down, full-inference (volatile) speeds it up, ±25% per step, clamped to `[¼×, 4×]` of a configured base. This is real, executing code, faithful to the design in the sibling StarForth repo's own `docs/working/architecture/03-architecture/heartbeat-system/architecture.md` (Option A vs. Option B). It is not a proposal — it already runs, on every `vm_tick()`. It is orphaned. `tick_target_ns` is written to `vm->heartbeat.worker->tick_ns` (`include/vm.h:286,289`), and `worker` is always `NULL` in kernel builds — nothing in `src/starkernel/` reads `tick_target_ns` at all. The mechanism that was meant to *consume* it, a `pthread_create()`-based background worker (`heartbeat_thread_main()`, vendored into `vm_bootstrap.c:310`), is deliberately and redundantly disabled for kernel builds (`Makefile.starkernel:293,338`, `HEARTBEAT_THREAD_ENABLED` forced to `0` twice) — correctly: this is a bare-metal single-hart kernel, there is no pthread implementation, and the mechanism's own history (`segfault-analysis.md` in the StarForth repo) is a real concurrent-access bug against `RollingWindowOfTruth`, fixed by a mutex a single hart doesn't need and can't cheaply provide (§21.2, item 0.9). So: the *decision logic* is real, tested by inheritance, and currently produces a number nothing downstream ever reads. Phase 0 as written would ship a heartbeat that looks adaptive in the source tree and is not adaptive on the wire. ### 26.2 This does not reopen GAP-A1 §18.5 already proved the general shape of this argument for Loop #5 and is directly reusable for Loop #7: **adaptation is safe exactly when its inputs are execution-derived**, because then the *decision* to change the period is itself a deterministic function of the execution stream, not of wall-clock jitter. §18.5 point 3 already establishes that every `InferenceInputs` field feeding this ANOVA machinery — rolling window, trajectory length, prefetch hit rate, hot/stale word counts, total heat, word count — is execution-derived with zero timing input. Loop #7's stable/volatile classification is downstream of that same machinery. Nothing new needs proving there. What must not change, and does not under this design: - The **virtual tick stays the engine's clock** (§18.4, unchanged). Adjusting the physical re-arm period changes *when TIME-TRUST samples land and how often the idle path wakes* — it does not move decay, reap, or inference off the virtual tick onto the hardware one. - The hardware tick's **output** still feeds nothing that reaches the parity hash (§18.5 points 1, 5, unchanged) — only its *input* (the period it's told to re-arm at) becomes execution-derived instead of fixed. - What was already true and already inert under §18.5 — that wall-clock interrupt *arrival* timing is not reproducible run to run — stays true and stays inert. Nothing patron-facing ever depended on it; this design doesn't change that. ### 26.3 The scale mismatch, and the ruling `tick_target_ns`'s configured base, `HEARTBEAT_TICK_NS` (`include/starforth_config.h:71`), is `10000ULL` — 10 microseconds. Its live range under Loop #7's ±25%/`[¼×,4×]` bounds is therefore 2.5µs–40µs. The hardware timer configured throughout items 0.1–0.7 runs at 100 Hz — 10 milliseconds. That is a three-orders-of-magnitude mismatch: reprogramming the physical re-arm to the literal `tick_target_ns` value would fire the timer 25,000–400,000 times a second, which on a bare-metal single-hart kernel means the core spends effectively all its time in trap entry/exit and the REPL is never reached. `HEARTBEAT_TICK_NS` was tuned for a hosted OS thread's sleep granularity, not a bare-metal ISR period. **RULED (Captain Bob, 2026-08-03):** same relationship, kernel-appropriate scale. The mechanism must be real and load-bearing — a genuine, measurable effect on the physical re-arm period, the same kind of effect the original hosted pthread experiments showed — but computed against the 10 ms / 100 Hz base already established for this kernel, not the 10 µs hosted base. Loop #7's *decision logic* (stable → slower, volatile → faster, ±25% per step, clamped `[¼×, 4×]`) is reused unmodified; only the base it is applied to changes. ### 26.4 The mechanism — no thread needed Captain Bob authorized building a kernel-native thread/task if one were required ("if we gotta run a thread or whatever, it doesn't matter"). One is not required, and adding one would need a preemptive scheduler this single-hart kernel does not have (§21.2 already rules out real locking for exactly this reason). The existing shape gets there without new infrastructure: - `vm_tick()` already calls Loop #7 on the **mainline path** (execution-paced, never in interrupt context) and already produces a fresh `tick_target_ns` there. - Item 0.8 already introduces a shared `heartbeat.c` owning the top/bottom-half split. That file is the natural owner of one new piece of state: the *current adaptive period*, set by a new `heartbeat_set_adaptive_period_ns(uint64_t ns)` (called from `vm_runtime.c`'s Loop #7 site, scaled to the kernel base per §26.3) and read by a new `heartbeat_next_period_ns(void)`. - Each architecture's existing re-arm function (`apic_timer_rearm()`, `riscv64_timer_rearm()`, the aarch64 equivalent) already runs in interrupt context at the top of every tick (§18.4's "one tick" call sites, unchanged). It converts `heartbeat_next_period_ns()` to that architecture's raw counter units — a conversion each already does today for its fixed period — instead of using a hardcoded constant. No new concurrency: the write happens on the mainline execution path, the read happens in interrupt context, and the value read is whatever was last written — the same single-writer/ single-reader shape every other piece of ISR-read, mainline-written state in this kernel already has (§21.1's finding that locking here is already free, because nothing here is actually concurrent on one hart). No pthread, no kernel task, no scheduler. ### 26.5 Open, deferred - Multi-VM: today Hera is the only VM, so "whose `tick_target_ns` drives the one physical timer" has one answer. Not resolved for when Hermes/Artemis return — deferred, not applicable yet (consistent with §20.5's other Tripod-return deferrals). - `HEARTBEAT_TICK_NS`'s name and its hosted-scale value are unchanged by this ruling — the kernel-side base (10 ms) is a **separate** constant, not a redefinition of the hosted one. Naming the kernel constant is an implementation detail of the item that builds this, not a document-level open question. **RULED.** Item 0.8 is amended below to include this; no new punch-list item is needed — this is squarely inside what 0.8 already builds (`heartbeat.c`, the three re-arm call sites). ## 27. Console — the drawing fabric (groundwork, 4.3.1) **STATUS: design-stage, 2026-08-07.** Supersedes `.claude/CONSOLE.md` as the authoritative Console design document. `CONSOLE.md` was a rough prior working draft and is not edited further; nothing in it should be treated as decided just because it is written down there. This section covers 4.3.1–4.3.4 only — the groundwork slice: verify the hardware boundary works and stand up the coordinate machinery. It is explicitly *not* the full Console specification (fonts, scrolling-as-a-VM-behavior, message protocol from Hermes, etc.) — those are later items under 4.3, scoped once this slice's checkpoint (4.3.4) is reviewed. ### 27.1 The hardware boundary already exists (4.3.1) `console_fb_init()` (`hal/console.c:269`) calls `fb_init()` with real UEFI GOP data from `boot_info->framebuffer`, but only after POST completes, immediately before the REPL starts (`kernel_main.c:800-807`). `include/starkernel/framebuffer.h` already exposes raw pixel primitives: `fb_put_pixel`, `fb_fill_rect`, `fb_draw_glyph` (8×16 cells), `fb_scroll_rows`. There is nothing to build to get pixels on screen — 4.3.1's task is *verification*, not construction: draw a simple, orientation-revealing test pattern with the existing primitives and confirm it displays right-side up. ### 27.2 No screenshot capability exists today (4.3.2) All three `qemu` targets in `Makefile.starkernel` run with `-display none` and attach only a serial chardev socket (for the log) — no monitor, no QMP socket. There is no way today to issue QEMU's `screendump` command. 4.3.2 adds a monitor/QMP socket (mirroring the existing serial-socket pattern) so the framebuffer can actually be inspected as a `.ppm` after a run. ### 27.3 Coordinate system (4.3.3, 4.3.3a, 4.3.3b) - **Origin bottom-left**, `(0, 0)`. Traditional Cartesian, not raster/top-left-Y-down. **Reinterpreted 2026-08-07:** §27.3 originally said the Y-flip "must live at the lowest primitive layer," written before the language split (below) was decided. `PLOT` is the true hardware boundary and is deliberately raster-native with no Cartesian awareness at all — same posture as `BLOCK`/`UPDATE` staying dumb about policy. `CART-Y`, in FORTH, is the lowest *Cartesian-aware* layer, which satisfies the original intent (nothing above it ever thinks about the flip) even though the flip itself lives one level up from the raw pixel write. - **Z axis, depth-into-screen** (not height-off-ground). Confirmed 2026-08-07: this is heading toward real 3D animation over time, not a single static scene — Z is being added now because retrofitting it later is more expensive than building it in from the start. - **Projection: fixed orthographic, for now.** Explicitly a placeholder — not the final projection, no perspective/camera work yet. **Angle settled 2026-08-07: true 45° cavalier** (both X and Z axes drawn at 45° off horizontal) — `sx = x + z·cos45`, `sy = y + z·cos45`. Ruled out the 2:1 pixel-art isometric convention (~26.57°); no reason recorded beyond preference. - **Language: FORTH for the transform, C only for the raw pixel write.** Per the project's compose-in-FORTH-first rule — `PROJECT`/`CART-Y`/`CART-PLOT` are policy, not hardware access, so they belong in `capsules/fabric.4th`, not in C. Only `PLOT`/`FB-WIDTH`/ `FB-HEIGHT` are C primitives. - **Trigonometry (4.3.3a) and geometry primitives (4.3.3b), raised 2026-08-07 while scoping this item.** `Q.SIN`/`Q.COS` (Taylor series, radian input) extend `q48_16.c` the same way `Q.LOG`/`Q.EXP`/`Q.SQRT` already do — not a new precedent, just more of the same module. `LINE`/`CIRCLE`/`ARC`/`ELLIPSE` build on those plus `PLOT`/`CART-PLOT`, entirely in FORTH, Q48.16 throughout. Resolution-agnostic by design — 48 integer bits is vastly more range than 1920×1080 needs, checked as a sizing sanity check only, not a hardcoded constraint. ### 27.4 Checkpoint: a cube (4.3.4) Acceptance for this whole slice is a cube rendered on screen using the 4.3.3 coordinate/ projection machinery — the first real exercise of that math, expected to take real effort, not a quick add. Stop and review here before scoping the next 4.3.x item. Out-of-scope list for 4.3.1–4.3.4 is recorded once, at the 4.3 punch-list entry itself (§25.5), not repeated per sub-item. ### 27.5 Keyboard input, interrupt-driven (4.3.5–4.3.5f) Scoped 2026-08-07, next after the 4.3.4 checkpoint. Purpose: get a real keypress into the kernel with no polling anywhere in the path, so the REPL keyboard-input work (M8, tracked outside Stadium — see `.claude/CLAUDE.md`'s roadmap section) has something to call. This section is groundwork only, same as 27.1–27.4 was for the framebuffer — it stops at "a keypress produces an event at a shared interface," not REPL line-editing, not a scancode- to-ASCII layout, not repeat/modifier-key semantics. Those are later items, scoped once 4.3.5f is reviewed. **Why this split is bigger than 4.3.3's three-way split.** 4.3.3/4.3.3a/4.3.3b all built on interrupt/timer substrate Phase 0 already finished. Keyboard input does not have that luxury on two of three architectures: - **amd64** has a Local APIC (for the self-interrupting timer) but no I/O APIC driver at all, and `pic_disable()` (`interrupts.c:228`) permanently masks the legacy 8259 — so today there is no path for *any* legacy IRQ, keyboard's IRQ1 included, to reach the CPU. 4.3.5 stands up the minimum I/O APIC needed for one redirection entry. - **riscv64** has never enabled external interrupts at all — Phase 0 (0.2/0.3) scoped strictly to the S-mode timer interrupt (`sie.STIE`). The PLIC (`sie.SEIE`, bit 9) is unimplemented; `interrupts.c`'s existing comment naming the PLIC describes the architecture, not working code. 4.3.5b is that bring-up, split out as its own item because it is substrate, not keyboard-specific — same shape as Phase 0's per-ISA items. **Found 2026-08-08 while implementing 4.3.5b: that substrate assumption itself had a substrate gap underneath it** — riscv64 has no software-controlled paging at all (see 4.3.5a, inserted ahead of the PLIC item once this surfaced). Phase 0 never needed to touch memory translation because the timer only ever touches CSRs and SBI calls, never MMIO; PLIC bring-up is the first riscv64 item to need a real MMIO write, and that is what exposed it. - **aarch64**'s GIC (item 0.6) was deliberately scoped to one PPI (the timer) and explicitly ruled a general GIC driver out of scope. A PCI `virtio-keyboard-pci` device signals via an SPI, a distinct GIC path 0.6 never touched. 4.3.5d is the minimal SPI extension, held to the same narrow-scope discipline 0.6 used. **Hardware path differs by architecture, same as the framebuffer did.** amd64's `q35` machine has a real i8042 PS/2 controller (QEMU default, IRQ1) — 4.3.5. Neither `virt` board (aarch64, riscv64) has PS/2 hardware; both get a `virtio-keyboard` device added to their QEMU command lines and a new virtio-input driver (this tree has none — `virtio_blk.c` is the only existing virtio driver, and it is fully synchronous/polled, so it is a transport reference only, not an interrupt-handling one) — 4.3.5c (riscv64, MMIO transport) and 4.3.5e (aarch64, PCI transport, riding the bus `pci.c` already enumerates for `virtio-blk-pci`). **No polling, anywhere, in any of 4.3.5/4.3.5c/4.3.5e.** This is the one hard constraint carried through every sub-item — restated per item because it is the actual point of doing interrupt bring-up first rather than reading `0x64`'s status bit or spinning on a used-ring index, which would have been the fast/wrong way to get a key on screen quickly. 4.3.5f is the checkpoint, same posture as 4.3.4: converge the three architecture-specific paths behind one interface before anything scopes REPL line input on top of it. ### 27.5.1 aarch64 GIC-SPI derivation for 4.3.5d/4.3.5e (2026-08-08) Two facts 4.3.5d/4.3.5e depend on were derived live rather than assumed, same discipline as `apic.c`'s own file header uses for the timer PPI values (item 0.6). **1. aarch64 has no 4.3.5a-shaped landmine.** `arch_mmu_init()` (`arch/aarch64/arch.c:169`) is the same kind of stub as riscv64's Sv39 gap — MMU bring-up "deferred to a later milestone" — so aarch64's MMIO also depends entirely on whatever EDK2 left mapped at `ExitBootServices()`. Live-probed rather than inferred: a temporary read-modify-write to `GICD_ITARGETSR` (offset `0x820`, within the same 4KB page as this item's real target offsets `0x823`–`0x826`) survived cleanly on a solo aarch64 boot — no exception, boot proceeded to `ok>` (log: `logs/20260808-093228/aarch64/`). Recorded at the 4.3.5d entry itself (§25.5); this subsection is the supporting derivation, not a duplicate record. **2. The PCI slot→SPI routing formula, decoded from QEMU's own DTB, not recalled.** Dumped with `qemu-system-aarch64 -machine virt,dumpdtb= -cpu cortex-a57` (QEMU 10.2.1, the exact binary and machine/cpu flags this tree's `qemu` target uses — `Makefile.starkernel:822-824`) and decoded by hand-parsing the FDT struct block (no `dtc` installed on this build host) for the `pcie@10000000` node's `interrupt-map` / `interrupt-map-mask` properties. `interrupt-map-mask` = `0x1800 0 0 0x7` masks PCI device number down to its low 2 bits (`slot mod 4`) and the full 3-bit INTx pin field — meaning the table's 16 explicit entries (slots 0–3 × INTA–D) cover **every** PCI slot QEMU assigns, not just 0–3, because routing repeats every 4 slots. Decoded table (INTID = GIC SPI number + 32; flags `0x4` = `IRQ_TYPE_LEVEL_HIGH` throughout): | slot mod 4 | INTA | INTB | INTC | INTD | |---|---|---|---|---| | 0 | INTID 35 (SPI 3) | INTID 36 (SPI 4) | INTID 37 (SPI 5) | INTID 38 (SPI 6) | | 1 | INTID 36 (SPI 4) | INTID 37 (SPI 5) | INTID 38 (SPI 6) | INTID 35 (SPI 3) | | 2 | INTID 37 (SPI 5) | INTID 38 (SPI 6) | INTID 35 (SPI 3) | INTID 36 (SPI 4) | | 3 | INTID 38 (SPI 6) | INTID 35 (SPI 3) | INTID 36 (SPI 4) | INTID 37 (SPI 5) | Closed form, used at runtime by 4.3.5d/e rather than a hardcoded constant (the aarch64 `qemu`/`qemu-esp` targets don't pin PCI slot addresses the way the riscv64 target's `addr=0x1`/`addr=0x2` do, so the keyboard's slot is whatever QEMU assigns): ``` pin = pci_read8(dev, PCI_CFG_INT_PIN) /* config offset 0x3D; 1=INTA .. 4=INTD */ slot = dev->device /* already populated by pci_find_first() */ spi = 3 + ((slot + pin - 1) % 4) intid = 32 + spi /* 35..38 */ ``` Not yet decoded/needed: entries for PCI bridge-forwarded interrupts or multi-function devices beyond function 0 — out of scope for a single virtio-keyboard-pci device on bus 0. ### 27.5.2 riscv64 PLIC-source derivation for 4.3.5c (2026-08-08) Same method as §27.5.1, applied to the riscv64 `virt` board's own DTB rather than assumed to match aarch64's — dumped with `qemu-system-riscv64 -machine virt,dumpdtb= -cpu rv64` (this tree's exact `qemu` target flags, `Makefile.starkernel:921-923`) and decoded the same way (hand-parsed FDT struct block, no `dtc`). **Transport correction that motivated this item's amendment.** The `soc/pci@30000000` node (`compatible = "pci-host-ecam-generic"`, `reg = 0x30000000 0x10000000`) confirms PCI/ECAM, matching `pci.c`'s existing riscv64 fallback base exactly — there is no MMIO virtio transport on this board, corrected at the 4.3.5c entry itself (§25.5). **PLIC's interrupt binding is one cell, not three.** `soc/plic@c000000`'s own `#address-cells = 0`, `#interrupt-cells = 1` (phandle `0x3`) — simpler than GIC's `` triple, so the `interrupt-map` entry stride here is 6 cells (3 child-addr + 1 child-irq + 1 phandle + 1 parent-irq), not GIC's 10. `interrupt-map-mask` is the identical `0x1800 0 0 0x7` (slot mod 4) pattern §27.5.1 found on aarch64 — same GPEX-family host bridge behaviour, confirmed independently rather than assumed carried over. Decoded table (phandle `0x3` on every entry, confirming it targets the PLIC node just read): | slot mod 4 | INTA | INTB | INTC | INTD | |---|---|---|---|---| | 0 | PLIC 32 | PLIC 33 | PLIC 34 | PLIC 35 | | 1 | PLIC 33 | PLIC 34 | PLIC 35 | PLIC 32 | | 2 | PLIC 34 | PLIC 35 | PLIC 32 | PLIC 33 | | 3 | PLIC 35 | PLIC 32 | PLIC 33 | PLIC 34 | Closed form: ``` pin = pci_read8(dev, PCI_CFG_INT_PIN) /* config offset 0x3D; 1=INTA .. 4=INTD */ slot = dev->device /* already populated by pci_find_first() */ source = 32 + ((slot + pin - 1) % 4) /* 32..35 */ ``` 4.3.5c pins the new device to `addr=0x3` (slot 3) explicitly in the Makefile rather than relying on QEMU's auto-assignment — deterministic, and keeps this table's slot-3 row as the one that actually matters for that item, though the runtime formula above holds regardless of slot. ### 27.6 Glyph rendering, UTF-8 Latin, capsule-loaded (4.3.6–4.3.6g) Scoped 2026-08-09, next after the 4.3.5f checkpoint, per the 2026-08-07 sequencing note (§25.5): keyboard input → glyph rendering → REPL, before the 4.6 Artemis boundary (renumbered from 4.4, then 4.5, 2026-08-11 — REPL now has its own 4.4 section, and Artemis moved down to make room for item 4.5's -O0 finding). **Purpose and boundary.** Render text onto the CANVAS region of the Stadium UI (per the reviewed mockup: a fixed REPL strip at the bottom of the screen, a separate large CANVAS region above it) using the stroke-drawing primitives 4.3.3b already built (`LINE`/`CIRCLE`/ `ARC`/`ELLIPSE` in `capsules/fabric.4th`). Additive, not a modification of the VT100 console text path. **Non-goal, explicitly deferred:** the CANVAS's actual scrollable framebuffer viewport (sizing, e.g. a 640×480 region) is REPL-wiring scope (M8), not this item. **Why `font_8x16.c` stays, structurally, not by preference.** The new font system is FORTH-capsule-based, so it only exists once the VM is up — `sk_vm_bootstrap.c`'s capsule/VM bootstrap is milestone M7, after console/PMM/VMM/interrupts/timer/kmalloc (M1–M6, `kernel_main.c`). Anything rendered before or during that window — early boot messages, the REPL itself — has no capsule system to draw from yet, so the baked-in `font_8x16.c` raster font remains the only thing that can render text that early. It stays, unchanged, as the VT100/REPL path's font regardless of what this item builds. **Boot wiring — resolved 2026-08-09.** The font capsule (and its `fabric.4th` dependency, not currently `EXEC`'d anywhere in `init.4th`) gets wired into `init.4th`'s boot chain, loading as early as the capsule/VM bootstrap allows — reversing 4.3.3's "Console isn't a fleet VM yet, not wired in" stance for this specific capsule pair. This is 4.3.6d, below. **Found, not wired — flagged, not built on.** `include/block_subsystem.h:168` declares a per-block `encoding` field (`0=ASCII, 1=UTF-8, 2=binary`) that nothing in the tree reads or writes — dead metadata. Not assumed functional; a ruling on wiring it up vs. ignoring it is still open, not blocking anything below. **Block namespace.** `fabric.4th` claims blocks 4900–4915 (confirmed against `capsules/BLOCK_MAP.md`); 4916+ in that family is free. Whether glyph work extends `fabric.4th` or lands in a new capsule file is an open call for whichever item creates it. Detailed derivation follows, §27.6.1–§27.6.6, worked out 2026-08-09 against confirmed primitives only (`cell_t = int64_t` — `include/vm.h:71`; `*/`, `WITHIN`, `CASE`/`OF`/ `ENDOF`/`ENDCASE`, `CREATE`, `EXECUTE`, `'`, `LSHIFT`/`RSHIFT`/`AND`/`OR` all confirmed registered in `src/word_source/*.c` — no word or syntax below was assumed without checking). No `$`/`0x` hex-literal syntax was found anywhere in this tree, so every formula below uses decimal literals only. ### 27.6.1 Em-square coordinate convention (4.3.6) `PROJECT`/`TO-RASTER` take plain Cartesian pixel-space integers, not Q48.16 — confirmed by reading `fabric.4th`: `Z->DELTA` is the only place Q48.16 conversion happens, and it is already integer-in/integer-out at the `PROJECT` boundary. So a glyph stroke needs one scale-and-translate step from a normalized em square into that same pixel space, then hands straight off to the existing `LINE`. **`EM-UNITS 1000`**, standard Type1/OpenType proportions: baseline `Y=0`, x-height `≈500`, cap-height `≈700`, ascender `≈750`, descender `≈-250`. Using `*/ ( n1 n2 n3 -- n4 )` = `n1*n2/n3` (confirmed C source, `arithmetic_words.c:212-234`: `int64_t intermediate = n1 * n2; result = intermediate / n3`). Worth noting: the "64-bit intermediate to avoid overflow" comment there is vestigial at this cell width — `cell_t` is *already* `int64_t` (`include/vm.h:71`), so the intermediate buys nothing over plain `* /` that classic 16-bit-cell FORTH-79 needed `*/` for. Not a bug, just an observation; `*/` is still used below for its self-documenting "scale by a ratio" idiom. ```forth VARIABLE GOX VARIABLE GOY VARIABLE GSIZE VARIABLE GCOLOR : EM-X ( em-x -- cart-x ) GSIZE @ EM-UNITS */ GOX @ + ; : EM-Y ( em-y -- cart-y ) GSIZE @ EM-UNITS */ GOY @ + ; VARIABLE GX1 VARIABLE GY1 VARIABLE GX2 VARIABLE GY2 : G-LINE ( gx1 gy1 gx2 gy2 -- ) GY2 ! GX2 ! GY1 ! GX1 ! GX1 @ EM-X GY1 @ EM-Y 0 GX2 @ EM-X GY2 @ EM-Y 0 GCOLOR @ LINE ; ``` Store-then-fetch via `VARIABLE`s rather than deep stack-juggling four values — matches the house style already used throughout `CIRCLE`/`ARC`/`ELLIPSE`/`EDGE`, not a new pattern. `GOX`/`GOY` are the glyph's screen-space baseline-left anchor; `GSIZE` the requested pixel size; both set by `DRAW-GLYPH` (§27.6.3) before a glyph word runs. **Precision note, stated not silently assumed:** `*/`'s C division truncates toward zero, not floor. For negative em-y values (descenders, below baseline) this loses sub-pixel precision (e.g. `-1 16 1000 */` = `0`, not `-1`) — not a bug, but on record given this project's history with exactly this class of truncation issue (`Q.TO-INT`, 4.3.3b). ### 27.6.2 UTF-8 decoder (4.3.6a) Standard, unambiguous algorithm — not a design choice, just correct implementation. Lead-byte classification by bit pattern: ```forth : UTF8-SEQ-LEN ( lead -- n ) \ 0 = invalid lead byte DUP 128 < IF DROP 1 EXIT THEN DUP 224 AND 192 = IF DROP 2 EXIT THEN DUP 240 AND 224 = IF DROP 3 EXIT THEN DUP 248 AND 240 = IF DROP 4 EXIT THEN DROP 0 ; : UTF8-CONT? ( byte -- flag ) \ true if 10xxxxxx continuation byte 192 AND 128 = ; ``` Codepoint assembly formulas (the specification `DECODE-UTF8` must compute — the exact FORTH stack mechanics for consuming 1-4 bytes from a buffer and returning both the codepoint and the advanced pointer are left to 4.3.6a's own implementation and live testing, per this project's demonstrated history of subtle stack/precision bugs surfacing only under real testing, not design review — presenting untested stack-juggling code here as settled would be exactly the kind of confidently-wrong mistake §25.0 rule 4 warns against): - 1-byte: `codepoint = lead` - 2-byte: `codepoint = ((lead AND 31) LSHIFT 6) OR (cont1 AND 63)` - 3-byte: `((lead AND 15) LSHIFT 12) OR ((cont1 AND 63) LSHIFT 6) OR (cont2 AND 63)` - 4-byte: `((lead AND 7) LSHIFT 18) OR ((cont1 AND 63) LSHIFT 12) OR ((cont2 AND 63) LSHIFT 6) OR (cont3 AND 63)` **Correction found while scoping the character list (§27.6.4): the 3-byte path is v1-required, not future-i18n-only.** Smart quotes/en-dash/em-dash/ellipsis are Unicode General Punctuation (U+2000–U+206F), which is *not* Latin-1 Supplement despite being commonly lumped in with "Latin typography" — it encodes as 3-byte UTF-8. Originally assumed 3/4-byte decoding was pure future-proofing; it is not, once those specific characters are in scope. ### 27.6.3 Codepoint → glyph dispatch (4.3.6b) `WITHIN`'s exact semantics confirmed against `logical_words.c:370-384`, not assumed from the ANS spec: `( n low high -- flag )` = `low <= n < high` (inclusive-low, exclusive-high). Dispatch is a bucketed `CASE`/`OF`/`ENDOF` chain (not the flat 256-entry execution-token table originally proposed — deliberately chosen over the table despite the table composing more cleanly with override, see §27.6.5). `CASE` bodies cannot span block boundaries (4.3.3b finding), so ~113 glyphs are split into range buckets, each its own word: ```forth : DISPATCH-DIGIT ( codepoint -- em-advance ) \ 48-57 : DISPATCH-UPPER ( codepoint -- em-advance ) \ 65-90 : DISPATCH-LOWER ( codepoint -- em-advance ) \ 97-122 : DISPATCH-ASCII-PUNCT ( codepoint -- em-advance ) \ scattered 32-47,58-64,91-96,123-126 : DISPATCH-LATIN1 ( codepoint -- em-advance ) \ Latin-1 Supplement subset, §27.6.4 : DISPATCH-GENPUNCT ( codepoint -- em-advance ) \ General Punctuation subset, §27.6.4 : DISPATCH-GLYPH ( codepoint -- em-advance ) DUP 48 58 WITHIN IF DISPATCH-DIGIT EXIT THEN DUP 65 91 WITHIN IF DISPATCH-UPPER EXIT THEN DUP 97 123 WITHIN IF DISPATCH-LOWER EXIT THEN DUP 32 127 WITHIN IF DISPATCH-ASCII-PUNCT EXIT THEN DUP 160 256 WITHIN IF DISPATCH-LATIN1 EXIT THEN DUP 8192 8304 WITHIN IF DISPATCH-GENPUNCT EXIT THEN DROP TOFU ; : DRAW-GLYPH ( codepoint x y size color -- em-advance ) GCOLOR ! GSIZE ! GOY ! GOX ! DISPATCH-GLYPH ; ``` Each bucket word ends with the same default-clause pattern already used by `doe.4th`'s `WL-HI` (`DROP` the unmatched selector, then the default expression) — not a new idiom: ```forth : DISPATCH-DIGIT ( codepoint -- em-advance ) CASE 48 OF G-0 ENDOF 49 OF G-1 ENDOF ( ... ) 57 OF G-9 ENDOF DROP TOFU ENDCASE ; ``` **Glyph-word contract: `( -- em-advance )`.** Every glyph word (`G-A`, `G-0`, …) draws itself via `G-LINE` using the `GOX`/`GOY`/`GSIZE`/`GCOLOR` context `DRAW-GLYPH` already set, then leaves its own advance width in em-units on the stack (per the proportional-width decision, §27.6.4). `TOFU ( -- em-advance )` draws an empty box roughly cap-height tall and returns a fixed default advance — proposing `500` (half an em); flag if a different default is wanted. **Block budget, concretely estimated.** `WL-HI` (8 entries, one line each) fits in one block with room to spare. At a conservative ~15 entries/block: `DISPATCH-UPPER`/`DISPATCH-LOWER` (26 each) need ~2 blocks apiece, `DISPATCH-DIGIT` (10) needs 1, `DISPATCH-ASCII-PUNCT` (~32) needs ~2-3, `DISPATCH-LATIN1`+`DISPATCH-GENPUNCT` (18 combined, §27.6.4) need ~1-2. **~8-10 blocks for dispatch alone**, before any glyph's actual stroke data — on record so 4.3.6c isn't scoped against a fantasy budget. ### 27.6.4 Character list, confirmed 2026-08-09 (4.3.6c) **Correction to the original "ASCII + Latin-1 typographic set" framing:** curly quotes, en/em dash, and ellipsis are *not* Latin-1 Supplement (U+0080–U+00FF) — they're Unicode General Punctuation (U+2000–U+206F), a separate, non-contiguous block. The confirmed v1 repertoire spans both, correctly bucketed: - **ASCII printable** (32–126): all 95, `DISPATCH-DIGIT`/`DISPATCH-UPPER`/`DISPATCH-LOWER`/ `DISPATCH-ASCII-PUNCT`. - **Latin-1 Supplement** (11): `°` `©` `®` `±` `×` `÷` `¢` `£` `§` `¶`, plus non-breaking space (U+00A0 — non-printing, advance-only glyph, no visible stroke). - **General Punctuation** (7): `'` `'` (U+2018/2019), `"` `"` (U+201C/201D), `–` `—` (U+2013/2014), `…` (U+2026). **113 glyphs total for v1** (95 + 11 + 7), each needing its own stroke-drawing word plus a `CASE OF...ENDOF` entry in its bucket. ### 27.6.5 User font override (4.3.6e) **Real constraint, found while deriving, not designed around silently.** `CASE`/`OF`/`ENDOF` compiles an early-bound call — `DISPATCH-UPPER`'s `CASE` body bakes in a call to `G-A`'s address at the point `DISPATCH-UPPER` is *compiled* (during the default font capsule's load), the same way 4.3.3b found `CART-PLOT`-style redefinition only affects code compiled *after* the redefinition. A user-font capsule that later redefines `G-A` alone does **not** change what `DISPATCH-UPPER` calls — the override silently does nothing. This is the direct cost of choosing `CASE` dispatch over the flat xt-table (which would have made override a trivial table-slot overwrite, checked live via `@` on every dispatch). **Resolved 2026-08-09: override replaces the whole bucket word, not individual glyphs.** A user-font capsule that wants to change even one letter must redefine the entire relevant `DISPATCH-*` word (e.g. all of `DISPATCH-UPPER` to change one uppercase letter) — no new data structure, pure `CASE`, at the cost of override granularity. Worth stating in the open item itself, not discovered fresh when 4.3.6e is implemented. ### 27.6.6 `TEXT` entry point, algorithm-level (4.3.6f) `TEXT ( c-addr u x y size color -- )` walks the UTF-8 byte string left-to-right: decode one codepoint via `DECODE-UTF8` (§27.6.2), call `DRAW-GLYPH` with the running cursor position to draw it and get back its `em-advance`, scale that advance to pixels via `GSIZE @ EM-UNITS */ ` (same idiom as `EM-X`/`EM-Y`), accumulate into the cursor X, repeat until the buffer is exhausted. Presented at the algorithm level, same caveat as `DECODE-UTF8` — the exact loop construction is 4.3.6f's own implementation work, not settled here. *Refs:* §25.5 (punch list). ### 27.7 TrueType rendering, adjunct to the stroke font (4.3.7–4.3.7f) Scoped 2026-08-09, immediately after the 4.3.6g checkpoint review (which surfaced this decision ahead of that item finishing — see 4.3.6g's paused note). **Adjunct, not a replacement:** the stroke-drawn font system (4.3.6–4.3.6g) stays; nothing from it is being reverted or deprecated by this work. **Four design decisions resolved 2026-08-09, before any implementation:** 1. **Implementation layer: C, not FORTH.** TTF parsing (table lookups, glyph index resolution) and rasterization (Bézier flattening, scanline fill) are impractical to write as interpreted FORTH — same reasoning that put `LINE`/`CIRCLE`/`ARC`/`ELLIPSE` (4.3.3b) in C rather than FORTH. A new C module, parallel to those, exposes a handful of new words to FORTH. 2. **Q48.16 fixed-point throughout, not float.** Checked before deciding, not assumed. **GAP-B2** (this doc, §"unverified prerequisites") already flagged the *question* — "if the kernel is not built with `-mgeneral-regs-only` (aarch64) / soft-float (riscv64), a C interrupt handler may clobber FP registers" — as unverified, one grep away from settled. That grep was run while scoping this item: `Makefile.starkernel`'s `ARCH_CFLAGS` show amd64/aarch64 with no explicit FPU disable and riscv64 built `lp64d` (a hard-float ABI, not soft-float) — so hardware FP is implicitly available on all three, and GAP-B2's hypothesized risk is real, not hypothetical: none of the three architectures' ISR paths save/restore FP register state. This resolves GAP-B2's open question (confirmed real) but does not fix the underlying gap itself, which stays open for whatever eventually does need hardware FP. There is no documented project-wide "no float" policy — this is a targeted decision for this item. Building the rasterizer in Q48.16 sidesteps the risk for this work specifically (it never touches FP hardware), and incidentally keeps it consistent with the determinism convention the rest of the runtime (Compudynamics, the geometry primitives) already uses Q48.16 for. 3. **Font data storage: encoded into capsule blocks.** A `.ttf` is binary; capsule blocks are text (64 chars/line, 16 lines/block, per `tools/mkcapsule.c`'s `validate_forth_blocks()`). Chose hex/base64 encode-at-build/decode-at-load over a parallel build-time embedded-binary asset (like `capsule_generated.c` bakes in capsule text) specifically to keep font data inside the existing content-addressed capsule system rather than introduce a second, parallel asset-embedding mechanism. Hex vs. base64 not yet chosen — 4.3.7b's own call. 4. **Relationship to the stroke font: TrueType becomes the primary text path.** Once `TTF-TEXT` (4.3.7e) exists, it's the intended path for real text rendering; `TEXT`/the stroke system (4.3.6f) remain available and are not deprecated — useful for early boot (before the capsule/VM bootstrap, same reason `font_8x16.c` stays per §27.6), low-memory contexts, or diagnostic/geometric use. Both continue to exist; callers choose. **Explicitly not decided here, left to the item that owns them:** antialiasing approach (4.3.7c), hex vs. base64 encoding (4.3.7b), which `.ttf` file serves as the v1 test/default font (not chosen yet — needs a licensing check before any specific font is embedded). **Update 2026-08-10 — v1 test/default font chosen:** `JetBrainsMono-Regular.ttf`, licensed SIL OFL 1.1 (permissive, embedding/redistribution allowed) — resolves the licensing-check blocker above. **Correction 2026-08-10, superseding both the paragraph above and decision #3:** the "open question" recorded above was wrong — a grep-shallow check (`grep -n "blob\|binary"`) missed the actual mechanism. A full read of `tools/mkcapsule.c` shows raw-binary-blob ingestion already exists, end to end, and decision #3's hex/base64-encoding premise is unnecessary: - `process_file()` reads *any* regular file under `capsules/` (recursively, via `nftw`) into a `CapsuleEntry` as raw bytes. `validate_forth_blocks()` — the 64-char/16-line text check — only runs when the filename ends in `.4th`; every other file is accepted as-is, unvalidated and unmodified. - `generate_output()`'s payload-arena emission (`const uint8_t capsule_arena[] = { 0x%02X, ... }`) is a plain byte array, not a string literal — binary-safe, no NUL-termination assumption, already exercises correctly on non-text content. - Kernel-side, `capsule_find_by_name()` (`include/starkernel/capsule.h`) returns a `CapsuleDesc*` (`offset`/`length` into the arena) independent of `capsule_exec_payload()` (`capsule_loader.h`) — lookup and FORTH execution are already separate calls. A caller can fetch a capsule's raw bytes by name and simply never call `capsule_exec_payload()` on it. So: drop `.ttf` bytes into `capsules/` as-is (e.g. `capsules/fonts/JetBrainsMono-Regular.ttf` → capsule name `fonts:JetBrainsMono-Regular.ttf`), fetch via `capsule_find_by_name()`, done — no hex/base64 text-encoding, no `mkcapsule.c` changes, no parallel asset mechanism. Decision #3 is superseded by this. 4.3.7b's remaining work is just wiring a `kmalloc` copy (or direct-arena reference — 4.3.7b's own call) behind that lookup. *Refs:* §25.5 (punch list), GAP-B2. ### 27.8 REPL: prompt, text path, CANVAS geometry (4.4–4.4s) Scoped 2026-08-11, in conversation with Captain Bob, replacing `docs/lithosananke/ROADMAP.md`'s M8 section entirely (marked OBSOLETE there, kept for history only). Original visual reference: [`repl-mockup.png`](repl-mockup.png). **What this item is not.** Not a new subsystem — every piece below either reuses something already built (`USE`, `console_set_vm_name()`, `vt100.c`'s CSI/SGR parser, `TTF-TEXT`'s color argument) or extends it in a small, specific way. The scoping pass below exists because two things Captain Bob assumed were future work turned out to already exist, and one instruction ("VT100 is completely obsolete") turned out to need a narrower reading once the code was actually read — all found by checking the tree per §25.0 rule 4, not by assumption. **Found already built, not future work:** - **VM attach by name.** `USE` (`src/starkernel/capsule/mama_forth_words.c:1108`) already sets `g_repl_active_vm` (`repl.c:48`) and calls `console_set_vm_name()` — the REPL can already attach to any named VM (Hera, Hermes, Artemis, any birthed child) today. Nothing new needed here; 4.4 only changes how that identity is *displayed*. - **Two existing, inconsistent VM-identity displays.** `console.c`'s `g_active_vm_name` prefixes every console line with `[Name]` — this is what has appeared on every serial log line all session (`[Hera] ...`). Separately, `repl.c`'s `sk_repl_run()`/`sk_repl_step()` build the prompt itself as `)ok>` for non-Hera VMs (e.g. `Hermes)ok>`), and `zuse)ok>`/`ok>` for Hera depending on `vm->zuse_session`. Neither matches Captain Bob's described `[VM] (user) ok>` format. 4.4 unifies both into one convention at the prompt itself; the per-line `[Name]` prefix on non-prompt output is unaffected (out of scope, not mentioned by Captain Bob, not touched here). - **Stroke-font color.** `TEXT ( c-addr u x y size color -- )` (§27.6.6) already takes a color argument — confirmed before assuming 4.4j would need to add one. **End goal, stated by Captain Bob 2026-08-11, applies to every item in this series:** the framebuffer console and the serial console must be functional **identically** — anything printed reaches both. This is the standard 4.4c–4.4h (and, once 4.4j lands, the ANSI-colored TTF path) are built to. **Found while verifying 4.4d by screendump — not future work, a pre-existing gap:** the whole VT100-on-framebuffer pipeline this series assumed was live (parser, cell grid, `draw_cursor_glyph`) has never actually been connected to the boot sequence. `kernel_main.c:830-834` deliberately skips `console_fb_init()` (comment: "superseded by the Console drawing-fabric redesign... deliberately not invoked here"), so `vt100_init()` never runs and `vt100_putc()` no-ops on every call. No console output — not REPL text, not boot/POST logs — has ever reached the framebuffer; only the framebuffer driver's own corner self-test blocks have. ROADMAP.md's now-obsolete M8 section claimed this path was "already live"; that was never actually screendump-verified, and turned out to be wrong. Given its own item, 4.4c, ahead of 4.4d, which depends on it. **Found, changes the shape of "VT100 is obsolete":** `vt100.c` is a complete ANSI/VT100 terminal state machine — CSI parameter parsing, cursor tracking, full SGR color support (`apply_sgr()`, 16/256-color, `ansi256_to_rgb()`) — not just a `font_8x16.c` wrapper. Its glyph-drawing call is a single, separate site (`put_char()` → `draw_cursor_glyph()`). Captain Bob's "completely 100% obsolete" instruction, read against this, resolves to: keep the CSI/SGR engine (it's exactly the "ANSI colorization throughout" mechanism wanted), retarget only the one glyph-draw call site from `font_8x16.c` to `TTF-TEXT` (4.4i/4.4j). This is not a reversal of the obsolescence call — the VT100 *console text path as currently wired* (feeding `font_8x16.c`-rendered REPL text) is still going away for user-facing text; the parser underneath it is what survives, repointed. **Confirmed, not assumed: TrueType is the primary user-facing text path.** This restates §27.7 decision #4, not a new decision — TTF-TEXT for anything a user actively works with (REPL, CANVAS), stroke font (`TEXT`) reserved for later retro/game-styled work. See 4.4a's correction note above for the one inherited imprecision (stroke font is not actually usable pre-bootstrap either — `font_8x16.c` is the real early-boot fallback, not "the stroke font" generically). **CANVAS coordinate layering.** Two distinct coordinate ideas are in play and should not be conflated: `TTF-TEXT`'s own coordinate primitive is Cartesian with origin at the bottom-left of the *physical framebuffer*, decoupled from REPL-strip sizing (resolved via `AskUserQuestion` 2026-08-11, implemented in `ttf_words.c` ahead of this item, verified by amd64 screendump showing y=60/400/700 rendering bottom/middle/top respectively). CANVAS-relative placement — the 640×480 scroll box centered within the region above the REPL strip — is a separate layout computation (4.4o) that translates into that same physical-framebuffer coordinate space before calling `TTF-TEXT`. The primitive doesn't know about CANVAS or the REPL strip; 4.4o's layout math does. *Refs:* §25.5 (punch list), §27.6, §27.7.