Punch list §C item complete. Corrects this morning's "structurally
excluded" framing -- the Hera-is-root case was reachable via
S" Hera" KILL, fixed in db66406. Broken-parent-chain half remains
genuinely unreachable and is no longer tracked as open.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7623 lines
504 KiB
Markdown
7623 lines
504 KiB
Markdown
# 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:
|
||
|
||
```
|
||
<area>: <what the item did> e.g. riscv64: real trap entry with SRET return
|
||
|
||
Punch list §25 item <id> complete.
|
||
<one or two lines on what was actually verified, not what was intended>
|
||
|
||
Co-Authored-By: <the implementing model's attribution line, per its harness>
|
||
```
|
||
|
||
### 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/<arch> -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=<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=<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 `<Name>)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 `<Name>)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/<arch>/kernel/`), never a hand-guessed `build/<arch>/` 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> <input>"` 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 <stdio.h>`/`<stdlib.h>`/
|
||
> `<string.h>`/`<signal.h>` 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=<file> -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=<file> -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
|
||
`<type num flags>` 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 `<Name>)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.
|