aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-18 20:41:37 +0200
committeruser <user@clank>2026-07-18 20:41:37 +0200
commit546d2f36f309a0d8ac88360361b6fcfc0167eef5 (patch)
treeaedcff087aeb83f4e386cccf4e34829e5955f06d
parenttools: gbdemo - scripted, screen-synced demo driver (+ the README demo) (diff)
downloadgbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.tar.gz
gbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.tar.xz
gbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.zip
docs: concise README (networking + demo gif up front); internals.md
README is now pitch -> proof (real capture gif) -> quick start -> docs pointers, with the network stack and gbhub/gbjoin/gbtype getting explicit billing. The full technical breakdown (memory model, syscall ABI, fork/exec/context switch, FS layout, toolchain war stories) moves to docs/internals.md, plus previously-undocumented sections on the network stack and the terminal/OSK.
Diffstat (limited to '')
-rw-r--r--README.md413
-rw-r--r--docs/internals.md239
2 files changed, 308 insertions, 344 deletions
diff --git a/README.md b/README.md
index ea574bb..b84cacb 100644
--- a/README.md
+++ b/README.md
@@ -1,368 +1,93 @@
# gbos
-A tiny **Unix-flavored microkernel for the Game Boy Color** (MBC5 cartridge).
-Not Linux, not FUZIX — a from-scratch cooperative kernel that borrows the V7
-*process model* (separate read-only text + per-process data, a syscall trap,
-round-robin scheduling) and squeezes it into the GBC's banked, MMU-less memory.
+A tiny **Unix-flavored OS for the Game Boy Color** — a from-scratch cooperative
+microkernel with fork/exec, a shell with pipes, a persistent filesystem, a
+color terminal on the LCD, and a **real TCP/IP stack over the link cable**.
+It boots, gets a DHCP lease, pings, resolves DNS, fetches web pages, and hangs
+out on IRC. On a 1997 handheld (well, an emulated one).
-This is a **scaffold**, but a *working* one: it assembles into a valid 32 KiB
-ROM, brings up a process table, and round-robin context-switches between two
-demo tasks that each `write()` a byte and `yield()`. Verified end-to-end in the
-`~/dev/gbc` emulator (headless serial console) — it streams `ABABAB...`.
+![gbos: boot, ping, IRC — all on a Game Boy Color](demo/gbos-demo.gif)
-## Why this shape (the hardware reality)
+*Real capture: cold boot → DHCP lease → `ping` → an IRC session, typed live
+into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
-- No MMU, no memory protection. "Unix" here = the API/process model, not isolation.
-- CPU only ever sees **64 KiB**; RAM is banked.
-- **Kernel code lives in ROM** (`$0000-$3FFF`, up to 8 MiB via MBC5) — free, read-only.
-- **Program text lives in ROM banks** (`$4000-$7FFF`) — the V7 "pure text" idea.
-- **Per-task RW state** (data/bss/heap/stack) lives in one **8 KiB cart-RAM bank**
- (`$A000-$BFFF`, MBC5, up to 128 KiB / 16 banks).
-- The context switch swaps banks, so it **must** run from **HRAM** (never banked)
- and never touch a swappable stack mid-swap.
+## What's in the box
-## Memory map
+- **Processes, the V7 way**: `fork`/`exec`/`wait`/`exit`, zombies + reaping,
+ background jobs (`spin &`), `ps`, `kill` — round-robin cooperative
+ scheduling across banked memory with an HRAM context switch.
+- **A shell** (written in C, running *on* the GB) with I/O redirection
+ (`>` `<`) and pipes (`|`), argument passing, and a cwd-aware prompt.
+- **Networking, for real**: the kernel owns SLIP over the link port, IPv4,
+ ICMP, UDP and TCP; userland gets BSD-ish sockets via one syscall. On top:
+ `dhcp` (boot-time lease), `ping`, `nslookup` (real DNS), `wget` (HTTP),
+ `chat`, and a colorized **IRC client** (`irc HOST [NICK]`, `/join` and
+ friends, SELECT for the keyboard).
+- **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server +
+ NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach
+ the real internet; `tools/gbjoin` attaches an interactive GB to it, and
+ `tools/gbtype` types into any GB's shell from the host.
+- **40×18 color terminal** on the LCD (4×8 font, two chars per tile, ANSI-ish
+ colors) + an **on-screen keyboard** on the window layer (SELECT toggles it).
+- **Persistent filesystem** on battery-backed cart RAM: inodes, nested
+ directories, per-process cwd — survives power-off.
+- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~30
+ tools ship in `usr/` (`cat`, `wc`, `head`, `ls`, `uptime`, …). Adding a
+ program is one `.c` file + a table entry.
-```
-$0000-$3FFF ROM0 kernel core (rst/IRQ vectors, init, sched, syscalls) - fixed
-$4000-$7FFF ROMX current task TEXT (read-only program code)
-$8000-$9FFF VRAM graphics (unused so far)
-$A000-$BFFF SRAM current task DATA/BSS/HEAP/STACK (MBC5 RAM bank)
-$C000-$CFFF WRAM0 kernel globals + kernel stack (never swapped)
-$D000-$DFFF WRAMX per-process u-area (SVBK bank) (reserved, not used yet)
-$FF80-$FFEE HRAM context-switch trampoline (111 bytes)
-$FFEF-$FFF2 HRAM bank shadows + switch target
-```
-
-## Process control block (`include/gbos.inc`)
-
-`STATE, PID, SP, ROMB(16b text bank), RAMB(data bank), WRAMB(u-area), PARENT, EXIT`
-— 10 bytes. `MAX_PROCS = 8`.
-
-## Syscall ABI
-
-User loads `C` = syscall number, args in `DE`/`B`/`HL`, then `rst $30`.
-Return in `A` (and `B` for `wait`). **The trap clobbers `BC`/`DE`/`HL`** (it uses
-`HL` to index the dispatch table), so userland must save any pointer it needs
-across a syscall (`push hl` / `pop hl`). Libc syscall stubs handle this for C.
-
-| # | name | status |
-|---|---------|--------|
-| 0 | exit | ✅ zombie + reparent orphans + wake waiter |
-| 1 | fork | ✅ copies the 8 KiB bank, child returns 0 |
-| 3 | write | ✅ `DE`=buf `B`=len → serial console |
-| 6 | exec | ✅ `B`=program id → maps ROM text bank, jumps in |
-| 7 | wait | ✅ blocks; reaps a zombie child → `A`=pid `B`=code |
-| 8 | getpid | ✅ |
-| 11| yield | ✅ cooperative switch |
-| 2 | read | ✅ blocking console input (1 byte) via external-clock serial |
-| 4,5,9,10 | open/close/kill/brk | ⛔ `ENOSYS` |
-
-### The shell (`usr/sh.c`)
-
-init `exec`s the shell (written in C). It reads a line, tokenizes it, and runs
-commands with **I/O redirection and pipes**:
-
-```
-$ echo hello > note # redirect stdout to a file
-$ cat note
-hello
-$ wc < note # redirect stdin from a file
-1 1 6
-$ cat readme | wc -l # pipe (via a temp file)
-2
-$ echo one two three | wc
-1 3 14
-```
-
-Operators must be space-separated (`cat readme > out`). Command names are
-resolved by the kernel (`sys_lookup` + `NameTable`).
-
-**How I/O routing works.** Each process has a `stdin`/`stdout` fd in its PCB
-(default `$FF` = console). `read`/`write`/`putc` route through the kernel
-(`KGetc`/`KPutc`): console when the fd is `$FF`, otherwise the filesystem
-(`getb`/`putb`). The shell forks a child, the child `setin`/`setout`s to open
-files, then `exec`s — so the program's I/O lands in the file. A pipe
-`a | b` is run as `a > __pipe ; b < __pipe ; rm __pipe`.
-
-### Process lifecycle (exit / wait / reaping)
-
-The classic Unix zombie/reap dance, adapted to banked memory:
-
-- **`exit(code)`** sets `PS_ZOMBIE` + status, **reparents** any children to init
- (pid 1), **wakes** the parent if it's blocked in `wait()`, then schedules
- away forever. Its resources are freed by the reaper, not here.
-- **`wait()`** scans for a `PS_ZOMBIE` child. Found → **reap**: free its
- cart-RAM bank (back to the allocator) and its PCB slot, return pid + code.
- Children exist but none dead → set self `PS_BLOCKED` and yield, retry on wake.
- No children → return `$FF` (`ECHILD`).
-- The scheduler skips non-`READY` procs; `FindNextReady` returns carry when
- nothing is runnable so `yield` just keeps the caller running (idle).
-- **Cart-RAM banks are a free list** (`wBankUsed` bitmap): `AllocRamBank` /
- `FreeRamBank`. Verified by running 6 workers through only 3 child banks
- (`w2w3w4w5w6w7`) — each reaped bank is recycled by the next `fork`.
-
-Lifecycle demo (`tasks.asm`): init forks 3 workers, each `exec`s PROG_WORKER
-(prints `w`, exits with its pid), init `wait`s and reaps all three →
-`wwwR2R3R4!`.
-
-### exec (`sys_exec`, src/proc.asm + programs.asm)
-
-Where the text-in-ROM model pays off. Programs are linked to run from the ROMX
-window (`$4000-$7FFF`) and stored in their own ROM banks (`programs.asm`).
-`exec(B=program id)`:
-
-1. looks up `ProgramTable[B]` = (ROM bank, entry),
-2. sets `PROC_ROMB` and maps the bank live into `$4000-$7FFF`,
-3. resets the stack to the top of the task's (already mapped) cart-RAM bank,
-4. `jp`s to the entry — it never returns.
-
-Proof it's really bank-driven: both demo programs are ORG'd at the **same**
-address `$4000` in **different** banks (02 and 03). The parent `exec`s
-`PROG_PING`, the child `exec`s `PROG_PONG`, and the output `1212...` (2000/2000,
-zero garbage) can only happen if each process executes its own bank via
-`PROC_ROMB`. A fuller exec would also copy `.data` from ROM and zero `.bss`.
-
-### fork (`sys_fork`, src/proc.asm)
-
-The interesting syscall. It enters on the parent's user stack (which lives in
-the `$A000-$BFFF` cart-RAM window), so before it can swap that window it
-**switches to a kernel stack in WRAM0**. Then it:
-
-1. allocates a free PCB slot + a fresh cart-RAM bank (bump allocator),
-2. copies the parent's whole 8 KiB bank → the child bank, 256 bytes at a time
- through a WRAM bounce buffer (only one cart-RAM bank is visible at once),
-3. plants a **switch-in frame** in the child at `Uafter-8` (`Uafter` = parent
- SP at entry): the copied return address is already there, so it just zeroes
- the saved `hl/de/bc/af` slots — the child resumes at the post-`fork` PC with
- `A=0`,
-4. fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID),
-5. restores the parent stack and returns the child pid.
-
-Verified: `init` forks a child (P/C alternate on serial); nested forks yield
-three independent processes with distinct pids (`123123...`).
-
-## The context switch (`src/hram.asm`)
-
-The crux. `hSwitchTo(DE=&incomingPCB)`:
-1. push full register context onto the **outgoing** task's stack
-2. save `SP` into the outgoing PCB (WRAM0, always mapped)
-3. program incoming banks: **RAM bank → SVBK → ROM text bank**
-4. load `SP` from the incoming PCB (its banks are now mapped)
-5. pop context, `ret` into the incoming task
-
-Runs from HRAM so changing `SVBK`/RAM-bank never pulls the rug out from `PC` or
-the stack. New tasks are bootstrapped with a fake frame (`ProcSetupStack`) so the
-first switch-in `ret`s straight to their entry point.
-
-## Build
+## Quick start
```sh
-make # -> gbos.gb (RGBDS: rgbasm/rgblink/rgbfix)
-make clean
-```
-
-Console output goes to the **serial port** (`tty.asm`). Easiest way to watch it
-is the project emulator's headless serial console:
-
-```sh
-~/dev/gbc/build/gbc gbos.gb --headless --uncapped # prints ABABAB...
-```
-
-A real gbos would render to VRAM; serial is the zero-VRAM debug channel.
-
-### Bring-up bugs already found & fixed (kept as cautionary tales)
-
-- **Stack vs. BSS clear:** the kernel stack (`SP=$D000`) grows down into
- `$CxFF`, so zeroing all of WRAM0 wiped the live return address. `ClearKernelRAM`
- now clears only `$C000-$CBFF`, leaving the stack region alone.
-- **Syscall ABI vs. dispatch:** `SyscallTrap` originally used `DE` to index the
- jump table, clobbering the `write()` buffer pointer passed in `DE`. Dispatch
- now indexes via `A`/`HL` only, preserving `DE`/`B` for the handler.
-
-## Writing programs in C
-
-gbos programs can be written in **C**, compiled with **SDCC** (the `sm83` port).
-This makes porting tiny Unix CLI tools realistic. The toolchain (`usr/`):
-
-- `usr/gbos.h` — the userland API (`writes`, `readc`, `nl`, `getpid`, `sexit`).
-- `usr/libc.s` — syscall wrappers (asxxxx asm). Each saves
- `BC/DE/HL` around `rst $30`, since the trap clobbers them but SDCC expects
- them preserved.
-- `usr/crt0.s` — entry: `call main`; then `exit(0)`. Linked first so `_start` is
- the `$4000` entry.
-- `usr/build.sh` — compiles a `.c` with SDCC's **native** toolchain
- (`sdasgb` + `sdldgb`), links code at `$4000`, and extracts the ROM-bank blob.
- The Makefile INCBINs it and registers it as a program.
-
-```c
-#include "gbos.h"
-void main(void) {
- writes("hello from C on gbos!", 21); nl();
- writes("my pid is ", 10);
- { char d = getpid() + '0'; writes(&d, 1); } nl();
-}
-```
-
-### libc
-
-Syscall wrappers (`usr/libc.s`): `writes`, `putc`, `puts`, `nl`, `strlen`, `readc`
-(returns `EOF`=4 at end of input), `getargs` (this program's argument string),
-`getpid`, `sexit`. C helpers (`usr/libc.c`, linked into every program): `putu`
-(print decimal), `atou` (parse decimal), `argv_parse` (tokenize into argc/argv).
+make # -> gbos.gb (needs RGBDS + SDCC)
-Arguments: the shell splits the command line at the first space and leaves
-`"cmd\0args\0"` at `$A000`; the child inherits it through `fork`, `getargs()`
-returns the raw arg string, and `argv_parse()` tokenizes it:
+# solo, in the sl0pboy emulator (~/dev/gbc):
+~/dev/gbc/build/sl0pboy --sixel 3 --chrome gbos.gb
-```c
-char *argv[8];
-unsigned char argc = argv_parse(argv, 8); /* args one two -> argc=3 */
+# networked — hub in one terminal, Game Boys in others:
+sudo tools/gbhub --daemon # virtual net + DHCP + NAT (needs root)
+tools/gbjoin # a windowed GB joins 10.0.0.0/24
+tools/gbtype 'ping 10.0.0.3' # or type into it from the host
```
-### CLI tools (in `usr/`)
-
-| tool | what it does |
-|------|--------------|
-| `echo` | print its arguments (`echo hello world`) |
-| `cat` | print a file (`cat readme`) or copy stdin to stdout |
-| `wc` | count lines/words/chars of a file or stdin (`wc -l readme`) |
-| `head` | first *n* lines of a file or stdin (`head -n 5 readme`) |
-| `ls` | list files |
-| `save` | read a line from stdin into a file (`save notes`) |
-| `rm` | delete a file (`rm notes`) |
-| `args` | argc/argv demo (`args one two three`) |
-| `uname`| print the system name |
-| `pid` | print the process's pid (decimal) |
-| `true` / `false` | exit 0 / 1 |
-| `chello` | the C "hello" demo |
-
-Options use `hasflag`/`optval` in libc: `wc -l`/`-w`/`-c`, `head -n N`.
+On the Game Boy: **SELECT** toggles the on-screen keyboard, d-pad + **A**
+types, **START** sends the line.
```
-$ echo C tools on a Game Boy
-C tools on a Game Boy
-$ args one two three
-argc=3
-argv[0]=one
-argv[1]=two
-argv[2]=three
-$ wc (input: "hello world\nfoo\n")
-2 3 16
-$ head 2 (input: alpha/beta/gamma/delta)
-alpha
-beta
+/# uname -a
+gbos sm83 (Game Boy Color)
+/# ping 10.0.0.1
+PING 10.0.0.1
+ reply from 10.0.0.1 seq=1
+ ...
+-- 4/4 received
+/# irc 10.0.0.1 sl0pboy
+-!- registering as sl0pboy (SELECT = keyboard)
```
-Each tool is a separate `usr/<name>.c`, built to a ROM-bank blob, INCBIN'd, and
-registered in the program table + shell command table (`src/programs.asm`).
-
-**Toolchain gotchas found the hard way:** SDCC's `--asm=rgbds` mode mis-orders
-instructions (emits `ld [hl],a` before the `ld hl,sp+0` that sets the pointer) —
-so we use SDCC's native asxxxx path and INCBIN the blob instead. String literals
-with embedded control chars are also mangled in rgbds mode; C programs pass
-explicit lengths and emit newlines via `nl()`.
-
-## Processes & jobs
+## The demo GIF
-Every process has a **pid** (1–255, monotonic) and a parent. `getpid`, `wait`,
-and a full lifecycle (`fork`/`exec`/`exit`/reaping) already existed; on top of
-that:
-
-- **`ps`** lists live processes (`pid state command`); state is `R`eady,
- `B`locked (in `wait`), or `Z`ombie. The PCB records the running program id
- (`PROC_PROG`) so `ps` can show names.
-- **`kill <pid>`** terminates a process (`sys_kill`): marks it a zombie,
- reparents its children to init, wakes a `wait`-blocked parent. **pid 1
- (init/the shell) is immortal** — `kill 1` is refused.
-- **Background jobs:** `cmd &` runs without waiting; the shell prints `[pid]`
- and reaps finished jobs (non-blocking `reap`), printing `[pid done]`.
-
-```
-$ spin & # a process that just yields forever
-[2]
-$ ps
-1 B sh
-2 R spin
-3 R ps
-$ kill 2
-[2 done]
-$ ps
-1 B sh
-5 R ps
-```
+The GIF above is produced by a scripted, self-syncing demo driver:
+`tools/gbdemo` runs a step file (`demo/readme.gbd`), injects console text and
+button events at the right moments — syncing on the *actual screen contents*
+(the terminal's WRAM shadow, read over the emulator's debug socket) rather
+than timers — while the emulator records every frame, then renders the GIF:
-## Filesystem
-
-A small **block-based, persistent filesystem** on battery-backed cart RAM
-(`src/blk.asm` + `src/fs.asm`). Layout on the 32 KiB "disk" (256-byte blocks):
-
-```
-block 0 superblock (magic + version)
-block 1 bitmaps (free blocks + free inodes)
-blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs)
-blocks 4.. data blocks
+```sh
+sudo tools/gbhub --daemon # network up (once)
+tools/gbdemo demo/readme.gbd # inside tmux; writes demo/gbos-demo.gif
```
-- **Inodes + nested directories.** A directory is a file of 16-byte entries
- (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct
- blocks); **32** inodes, 16 entries per directory.
-- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths
- (absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`,
- inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt.
-- **Bounce buffer.** Only `read_block`/`write_block` touch cart-RAM banking;
- everything else works on WRAM buffers. The bitmap + inode table are cached in
- WRAMX; data/dir blocks stream through a one-block cache.
-- **Persistent.** Formats on first boot (magic/version check), then survives
- power-off via the emulator's `.sav`.
-- Same syscalls as before (`open`/`getb`/`putb`/`list`/`remove`) so the tools
- (`cat`/`ls`/`save`/`rm`/`wc`/`head`) are unchanged.
+Edit `demo/readme.gbd` to choreograph your own (verbs: `type`, `slowtype`,
+`key`, `waitfor`, `record`, `gif`, …).
-```
-$ save notes
-gbos has a real filesystem now
-$ cat notes
-gbos has a real filesystem now
-$ mkdir docs
-$ cd docs
-/docs$ save note
-hi
-/docs$ cd /
-$ cat docs/note
-hi
-$ ls docs
-note
-$ wc notes # survives a reboot
-1 5 31
-```
+## Docs
-## Roadmap
+- [docs/internals.md](docs/internals.md) — the full technical breakdown:
+ memory model, syscall ABI, fork/exec/context-switch internals, the network
+ stack, the filesystem layout, C toolchain notes, war stories, roadmap.
+- [docs/link-drop-investigation.md](docs/link-drop-investigation.md) — a
+ networking bug hunt, preserved.
-- [x] `fork`: copy parent's 8 KiB RAM bank → free bank, child returns 0
-- [x] `exec`: point `PROC_ROMB` at a program in a ROM bank, reset stack, enter
-- [x] `exit` / `wait` / zombie reaping + cart-RAM bank recycling (free list)
-- [ ] `exec` refinement: copy `.data` from ROM + zero `.bss` for RW globals
-- [x] a real shell program: `fork`+`exec`+`wait` driven from the serial console
-- [x] C toolchain: SDCC (sm83) + libc shim, C programs run as gbos processes
-- [x] grow libc (`puts`/`putc`/`strlen`/`getargs`/EOF) + args via the shell
-- [x] CLI tools in C: `echo`, `cat`, `uname`, `pid`, `true`, `false`
-- [x] Makefile builds all C programs (a `CBLOBS` list)
-- [x] `wc`, `head`, and an `argc/argv` demo (`args`) + `argv_parse` in libc
-- [x] option parsing (`hasflag`/`optval`): `wc -l/-w/-c`, `head -n N`
-- [x] a RAM filesystem (WRAMX) + `ls`/`cat`/`save`/`rm`; `wc`/`head` read files
-- [x] shell in C with redirection (`>`/`<`) and pipes (`|`, via a temp file)
-- [x] per-process stdin/stdout routing (`KGetc`/`KPutc`, `setin`/`setout`)
-- [ ] more tools (`rev`, `grep`, `tail`), true concurrent pipes
-- [~] persistent filesystem rewrite (block-based, cart SRAM), directories:
- - [x] **stage 1**: block device (`blk.asm`) via a WRAM bounce buffer
- - [x] **stage 2**: inodes + root directory; variable-size files (<=2 KiB),
- bitmap allocators; 16 files / 32 inodes; persists across reboots
- - [x] **stage 3**: nested directories, path resolution, per-process cwd,
- `cd`/`mkdir`/`ls <dir>` — the whole tree persists across reboots
-- [ ] Preemptive scheduling: real context save in `TimerISR` → `hSwitchTo`
-- [ ] Use the `$D000-$DFFF` u-area (SVBK) for per-process kernel state / kstack
-- [ ] `brk`/heap allocator inside the task bank (heap up, stack down, collision = ENOMEM)
-- [ ] A filesystem in remaining cart SRAM/flash (minix/v7-ish), paged 8 KiB at a time
-- [ ] Swap whole tasks to cart flash when RAM banks are exhausted (UZI-style)
-- [ ] VRAM console + keyboard/joypad `read()`
-```
+Built and tested against the [sl0pboy](../gbc) emulator; runs on anything that
+emulates a CGB + MBC5 cartridge with battery RAM. No MMU, no protection, no
+regrets.
diff --git a/docs/internals.md b/docs/internals.md
new file mode 100644
index 0000000..c1007d1
--- /dev/null
+++ b/docs/internals.md
@@ -0,0 +1,239 @@
+# gbos internals
+
+The deep technical breakdown that used to live in the README. The short story
+is in [../README.md](../README.md); this is the long one.
+
+## Why this shape (the hardware reality)
+
+- No MMU, no memory protection. "Unix" here = the API/process model, not isolation.
+- CPU only ever sees **64 KiB**; RAM is banked.
+- **Kernel code lives in ROM** (`$0000-$3FFF`, up to 8 MiB via MBC5) — free, read-only.
+- **Program text lives in ROM banks** (`$4000-$7FFF`) — the V7 "pure text" idea.
+- **Per-task RW state** (data/bss/heap/stack) lives in one **8 KiB cart-RAM bank**
+ (`$A000-$BFFF`, MBC5, up to 128 KiB / 16 banks).
+- The context switch swaps banks, so it **must** run from **HRAM** (never banked)
+ and never touch a swappable stack mid-swap.
+
+## Memory map
+
+```
+$0000-$3FFF ROM0 kernel core (rst/IRQ vectors, init, sched, syscalls) - fixed
+$4000-$7FFF ROMX current task TEXT (read-only program code)
+$8000-$9FFF VRAM terminal (BG) + on-screen keyboard (window)
+$A000-$BFFF SRAM current task DATA/BSS/HEAP/STACK (MBC5 RAM bank)
+$C000-$CFFF WRAM0 kernel globals + kernel stack (never swapped)
+$D000-$DFFF WRAMX terminal shadow, FS caches (per-process u-area reserved)
+$FF80-$FFEE HRAM context-switch trampoline (111 bytes)
+$FFEF-$FFF2 HRAM bank shadows + switch target
+```
+
+## Process control block (`include/gbos.inc`)
+
+`STATE, PID, SP, ROMB(16b text bank), RAMB(data bank), WRAMB(u-area), PARENT, EXIT`
+— 10 bytes. `MAX_PROCS = 8`.
+
+## Syscall ABI
+
+User loads `C` = syscall number, args in `DE`/`B`/`HL`, then `rst $30`.
+Return in `A` (and `B` for `wait`). **The trap clobbers `BC`/`DE`/`HL`** (it uses
+`HL` to index the dispatch table), so userland must save any pointer it needs
+across a syscall (`push hl` / `pop hl`). Libc syscall stubs handle this for C.
+
+Core calls: `exit fork read write exec wait getpid yield kill` plus console,
+filesystem (`open getb putb list remove mkdir chdir`), process
+(`ps-info reap setin setout`), time (`uptime sleep`) and networking
+(`ssend srecv net` — see below) families.
+
+### The shell (`usr/sh.c`)
+
+init `exec`s the shell (written in C). It reads a line, tokenizes it, and runs
+commands with **I/O redirection and pipes**:
+
+```
+$ echo hello > note # redirect stdout to a file
+$ cat note
+hello
+$ wc < note # redirect stdin from a file
+1 1 6
+$ cat readme | wc -l # pipe (via a temp file)
+2
+```
+
+Operators must be space-separated (`cat readme > out`). Command names are
+resolved by the kernel (`sys_lookup` + `NameTable`).
+
+**How I/O routing works.** Each process has a `stdin`/`stdout` fd in its PCB
+(default `$FF` = console). `read`/`write`/`putc` route through the kernel
+(`KGetc`/`KPutc`): console when the fd is `$FF`, otherwise the filesystem
+(`getb`/`putb`). The shell forks a child, the child `setin`/`setout`s to open
+files, then `exec`s — so the program's I/O lands in the file. A pipe
+`a | b` is run as `a > __pipe ; b < __pipe ; rm __pipe`.
+
+### Process lifecycle (exit / wait / reaping)
+
+The classic Unix zombie/reap dance, adapted to banked memory:
+
+- **`exit(code)`** sets `PS_ZOMBIE` + status, **reparents** any children to init
+ (pid 1), **wakes** the parent if it's blocked in `wait()`, then schedules
+ away forever. Its resources are freed by the reaper, not here.
+- **`wait()`** scans for a `PS_ZOMBIE` child. Found → **reap**: free its
+ cart-RAM bank (back to the allocator) and its PCB slot, return pid + code.
+ Children exist but none dead → set self `PS_BLOCKED` and yield, retry on wake.
+ No children → return `$FF` (`ECHILD`).
+- The scheduler skips non-`READY` procs; `FindNextReady` returns carry when
+ nothing is runnable so `yield` just keeps the caller running (idle).
+- **Cart-RAM banks are a free list** (`wBankUsed` bitmap): `AllocRamBank` /
+ `FreeRamBank`. Verified by running 6 workers through only 3 child banks
+ (`w2w3w4w5w6w7`) — each reaped bank is recycled by the next `fork`.
+
+### exec (`sys_exec`, src/proc.asm + programs.asm)
+
+Where the text-in-ROM model pays off. Programs are linked to run from the ROMX
+window (`$4000-$7FFF`) and stored in their own ROM banks (`programs.asm`).
+`exec(B=program id)`:
+
+1. looks up `ProgramTable[B]` = (ROM bank, entry),
+2. sets `PROC_ROMB` and maps the bank live into `$4000-$7FFF`,
+3. resets the stack to the top of the task's (already mapped) cart-RAM bank,
+4. `jp`s to the entry — it never returns.
+
+Proof it's really bank-driven: both demo programs are ORG'd at the **same**
+address `$4000` in **different** banks (02 and 03). The parent `exec`s
+`PROG_PING`, the child `exec`s `PROG_PONG`, and the output `1212...` (2000/2000,
+zero garbage) can only happen if each process executes its own bank via
+`PROC_ROMB`.
+
+### fork (`sys_fork`, src/proc.asm)
+
+The interesting syscall. It enters on the parent's user stack (which lives in
+the `$A000-$BFFF` cart-RAM window), so before it can swap that window it
+**switches to a kernel stack in WRAM0**. Then it:
+
+1. allocates a free PCB slot + a fresh cart-RAM bank (bump allocator),
+2. copies the parent's whole 8 KiB bank → the child bank, 256 bytes at a time
+ through a WRAM bounce buffer (only one cart-RAM bank is visible at once),
+3. plants a **switch-in frame** in the child at `Uafter-8` (`Uafter` = parent
+ SP at entry): the copied return address is already there, so it just zeroes
+ the saved `hl/de/bc/af` slots — the child resumes at the post-`fork` PC with
+ `A=0`,
+4. fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID),
+5. restores the parent stack and returns the child pid.
+
+## The context switch (`src/hram.asm`)
+
+The crux. `hSwitchTo(DE=&incomingPCB)`:
+1. push full register context onto the **outgoing** task's stack
+2. save `SP` into the outgoing PCB (WRAM0, always mapped)
+3. program incoming banks: **RAM bank → SVBK → ROM text bank**
+4. load `SP` from the incoming PCB (its banks are now mapped)
+5. pop context, `ret` into the incoming task
+
+Runs from HRAM so changing `SVBK`/RAM-bank never pulls the rug out from `PC` or
+the stack. New tasks are bootstrapped with a fake frame (`ProcSetupStack`) so the
+first switch-in `ret`s straight to their entry point.
+
+## The network stack (`src/socket.asm`, `src/net.asm`)
+
+The LCD terminal + on-screen keyboard freed the link port from console duty,
+so **the link port is the network interface**. The kernel owns everything from
+the wire up: byte-level link transfers (`net.asm`), SLIP framing, IPv4 +
+checksums, ICMP (inbound echo is auto-answered while anything pumps RX), UDP,
+and a small TCP (SYN/ACK state machine, MSS 200, single in-flight segment).
+Userland sees one `SYS_NET` syscall: `socket / bind / connect / send / recv /
+recv_nb / close` driven by a request block — no program touches SLIP or IP.
+
+Userland networking is plain C on top of that: `dhcp` (DISCOVER→ACK, then
+`net_setip()`), `resolve.h` (dotted-quads + DNS A queries to 1.1.1.1),
+`netd` (background RX pump so pings are answered), `ping`, `nslookup`, `wget`
+(HTTP GET over kernel TCP), `irc` (a full client: channels, /join /msg /me,
+nick colors, flood-safe rendering) and `chat`.
+
+Bytes that arrive **outside** SLIP frames land in a console ring instead —
+that's how the host can type into the shell remotely (`tools/gbtype`).
+
+On the host side, `tools/gbhub` is a virtual switch/router: it spawns (or
+accepts, `tools/gbjoin`) several emulator instances, leases each an IP via
+DHCP (10.0.0.2+), routes GB↔GB traffic, floods broadcasts, and NATs the rest
+out a TUN device to the real internet. `tools/gateway.py` is the older
+single-GB gateway (echo/http/chat/irc bridge modes).
+
+## Filesystem
+
+A small **block-based, persistent filesystem** on battery-backed cart RAM
+(`src/blk.asm` + `src/fs.asm`). Layout on the 32 KiB "disk" (256-byte blocks):
+
+```
+block 0 superblock (magic + version)
+block 1 bitmaps (free blocks + free inodes)
+blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs)
+blocks 4.. data blocks
+```
+
+- **Inodes + nested directories.** A directory is a file of 16-byte entries
+ (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct
+ blocks); **32** inodes, 16 entries per directory.
+- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths
+ (absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`,
+ inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt.
+- **Bounce buffer.** Only `read_block`/`write_block` touch cart-RAM banking;
+ everything else works on WRAM buffers. The bitmap + inode table are cached in
+ WRAMX; data/dir blocks stream through a one-block cache.
+- **Persistent.** Formats on first boot (magic/version check), then survives
+ power-off via the emulator's `.sav`.
+
+## The terminal & keyboard (`src/term.asm`, `src/osk.asm`)
+
+- **40×18 color terminal on the CGB LCD**: a 4×8 font packs two characters per
+ background tile; tiles are line-bound so scrolling rewrites one tilemap row,
+ not the screen. ANSI-ish color escapes give per-cell fg/bg (see `usr/ansi.c`,
+ the IRC client's nick colors). An ASCII shadow of the screen lives in WRAM
+ (`TERM_BUF`, `$D600`) — which is what `tools/gbdemo` reads to sync demos.
+- **On-screen keyboard** on the window layer: SELECT toggles it, d-pad + A
+ types, START sends the line. The terminal underneath is never disturbed
+ (window enable is one LCDC bit).
+
+## Writing programs in C
+
+gbos programs are written in **C**, compiled with **SDCC** (the `sm83` port):
+
+- `usr/gbos.h` — the userland API; `usr/libc.s` — syscall stubs that preserve
+ `BC/DE/HL` around `rst $30`; `usr/libc.c` — helpers (`putu`, `atou`,
+ `argv_parse`, `hasflag`/`optval`, `msleep`).
+- `usr/crt0.s` — entry: `call main`, then `exit(0)`; linked first so `_start`
+ is the `$4000` entry.
+- `usr/build.sh` — compiles with SDCC's **native** toolchain (`sdasgb` +
+ `sdldgb`), links code at `$4000`, extracts a ROM-bank blob; the Makefile
+ INCBINs it and registers it in the program + shell command tables
+ (`src/programs.asm`).
+
+Arguments: the shell leaves `"cmd\0args\0"` at `$A000`; the child inherits it
+through `fork`, `getargs()` returns the raw string, `argv_parse()` tokenizes.
+
+**Toolchain gotchas found the hard way:** SDCC's `--asm=rgbds` mode mis-orders
+instructions (emits `ld [hl],a` before the `ld hl,sp+0` that sets the pointer)
+— so we use the native asxxxx path and INCBIN the blob instead. String
+literals with embedded control chars are also mangled in rgbds mode; C
+programs pass explicit lengths and emit newlines via `nl()`.
+
+## Bring-up bugs kept as cautionary tales
+
+- **Stack vs. BSS clear:** the kernel stack (`SP=$D000`) grows down into
+ `$CxFF`, so zeroing all of WRAM0 wiped the live return address.
+ `ClearKernelRAM` now clears only `$C000-$CBFF`.
+- **Syscall ABI vs. dispatch:** `SyscallTrap` originally used `DE` to index the
+ jump table, clobbering the `write()` buffer pointer passed in `DE`. Dispatch
+ now indexes via `A`/`HL` only.
+- **TCP field offsets vs. 8-bit adds:** socket-struct accessors do `add SK_x`
+ as an 8-bit immediate, so any field offset ≥256 silently truncates and lands
+ *inside* the RX buffer — keep TCP state ahead of `SK_RXBUF`.
+- **Console ring vs. burst injection:** one `net_pump` drains a whole serial
+ burst, so the 64-byte console ring must hold a full injected command line.
+
+## Roadmap
+
+- [ ] `exec` refinement: copy `.data` from ROM + zero `.bss` for RW globals
+- [ ] more tools (`rev`, `grep`, `tail`), true concurrent pipes
+- [ ] Preemptive scheduling: real context save in `TimerISR` → `hSwitchTo`
+- [ ] Use the `$D000-$DFFF` u-area (SVBK) for per-process kernel state / kstack
+- [ ] `brk`/heap allocator inside the task bank (heap up, stack down)
+- [ ] Swap whole tasks to cart flash when RAM banks are exhausted (UZI-style)