Carry project memory in the repo, with tools/memory-sync.sh

Claude Code keeps per-project memory at <home>/.claude/projects/<key>/,
where <key> is the project's absolute path with every separator and the
drive colon turned into a dash. That makes it both rename-fragile and
machine-specific, and this project has hit each of those once:

  - D:\DEV\Schunix -> D:\DEV\SchOS changed the key, and 43 memories were
    invisible until moved across by hand.
  - Development happens from Windows and from Linux, and each looks in a
    different place for the same checkout.

Committing .claude/memory/ fixes both -- git carries it across renames
and between hosts -- and `import` puts it wherever the local machine
expects. .claude/settings.local.json stays ignored: those are per-machine
permissions.

The colon maps to a dash rather than being dropped. Deleting it gives
D-DEV-SchOS, which matches nothing and silently finds zero memories; that
was the first attempt. On Git Bash the key comes from `cygpath -w`,
because Claude Code is a Windows program and sees D:\DEV\SchOS, not
/d/DEV/SchOS.
This commit is contained in:
Nexoniarz 2026-08-09 14:27:22 +02:00
commit 3182da3965
47 changed files with 2810 additions and 0 deletions

44
.claude/memory/MEMORY.md Normal file
View file

@ -0,0 +1,44 @@
- [SchOS rename + git](schos-rename-and-git.md) — Schunix is SchOS and the kernel is SchIFT; the tree is under local git, and the two bugs the rename caused.
- [Cross-platform build](schos-cross-platform-build.md) - Windows, Linux, macOS and BSD all build it; clang everywhere, and the four host assumptions that used to break.
- [Memory sync](schos-memory-sync.md) - memory lives in the repo at .claude/memory/; tools/memory-sync.sh moves it between machines.
- [Schunix build environment](schunix-build-environment.md) — builds natively on Windows with clang/lld + mingw + Git Bash; WSL is gone, and the five latent bugs the move exposed.
- [Syscalls: one table](schunix-syscall-single-table.md) — declare in kernel/sys/table.inc only; linux_abi.c is generated, never hand-edited.
- [Linux ABI decision](schunix-linux-abi-decision.md) — the native syscall ABI is being dropped; Linux numbering becomes the only ABI.
- [Rewrite priorities](schunix-rewrite-priorities.md) — what actually blocks the OS, in order, plus a confirmed SMP bug in the syscall entry path.
- [SMP and RAM-floor bugs](schunix-smp-and-memory-bugs.md) — SMP works (five bugs, and why __thread is wrong for per-CPU data); the 224 MB floor is gone.
- [SchFS and ramdisk root](schfs-v2-and-ramdisk.md) — the agreed BeFS/ZFS/btrfs synthesis, and the completed `make ramroot` + mkfs dedup work.
- [SchFS B-tree, phase 1](schfs-btree-phase1.md) — the CoW B-tree is built and passing; run `make schfs-test` after touching it.
- [SchFS phase 2: real mount](schfs-phase2-mount.md) — mkschfs writes images, the kernel mounts them read-only; `make schfs-check`, plus four traps worth remembering.
- [SchFS phase 3: writes](schfs-phase3-writes.md) — full write API, free map derived not stored, and `mount -t schfs` works; the VFS now dispatches through `struct fs_ops`.
- [DIRSIZ 14 -> 30](schunix-dirsiz-14.md) — filenames used to truncate silently; fixed by holding the HTree bucket at 256 bytes.
- [Linux open flags](schunix-linux-open-flags.md) — include/fcntl.h must hold Linux's values; a mismatch made `>>` truncate.
- [Shared memory and mmap](schunix-shm-and-mmap.md) — shm + file-backed mmap work; pages you don't own must never reach kfree.
- [Wall clock](schunix-wall-clock.md) — the RTC was never read, so `date` said 1970; kernel/core/time.c fixes it, plus the stale-image trap.
- [TTF rasteriser](schunix-ttf-rasteriser.md) — glyf outlines (not CFF, and why); the composite-glyph trap that loses the letter "i".
- [Text rendering](schunix-text-rendering.md) — the layer replacing the 8x16 cell grid; metrics, surfaces, coverage blending, UTF-8.
- [Console and TTY](schunix-console-tty.md) — ANSI parser, termios, UTF-8; the font is CP437 not Latin-1, plus five bugs that masqueraded as broken commands.
- [Build speed](schunix-build-speed.md) — no-op make is ~2s now; what made it 1m48 and the traps that would undo it.
- [PCI and drivers](schunix-pci-drivers.md) — bus enumeration, and where a driver that finds its own hardware goes.
- [ISO boot](schunix-iso-boot.md) — `make iso`; needs El Torito no-emulation + ATAPI because the loader never uses INT 13h.
- [Ramdisk root sizing](schunix-ramdisk-root-sizing.md) — the "disk full" was DOOM WADs, not FSSIZE; three Makefile knobs, and why excluding them is a licensing rule.
- [Framebuffer device](schunix-framebuffer-device.md) — /dev/fb0 is real and speaks Linux fbdev; never assume where the framebuffer lives.
- [Crash screen](schunix-crash-screen.md) — PSOGM lives in kernel/core/panic.c; `echo c > /proc/sysrq` to trigger, `make crashshot` to photograph.
- [QR encoder](schunix-qr-encoder.md) — kernel/core/qr.c for crash reports; `make qrcheck`, and the segno padding bug that is not ours.
- [Boot splash and logo](schunix-boot-splash-logo.md) — the loader draws the logo, not the kernel; STAGE2_SECTORS was in five places and is now in one.
- [SchAPI direction](schunix-schapi-direction.md) — Schunix as a macOS-shaped *system*; the stable ABI is a library, not the syscall table.
- [Roadmap and projects/](schunix-roadmap-and-projects.md) — the agreed order through to Schcom/SchWM/SchDE; the GUI and installer live outside the OS tree.
- [Driver infrastructure](schunix-driver-infrastructure.md) — dynamic IRQ registration, a DMA arena, PCI capabilities; PCI interrupts are level/active-low.
- [Network stack choice](schunix-network-stack-choice.md) — don't write TCP; lwIP over FreeBSD over NetBSD, with the licence evidence.
- [e1000 and driver layout](schunix-e1000-and-driver-layout.md) — the NIC driver, drivers/ split by device class, and virtio-gpu as the QEMU default.
- [virtio-gpu](schunix-virtio-gpu.md) — the display class's second implementation; run with `-vga std -device virtio-gpu-pci`.
- [Device model](schunix-device-model.md) — one bus-agnostic list at /proc/devices; bus is provenance, class is capability, and the settled Linux-driver decision.
- [usb.ids](schunix-usb-ids.md) — shipped as /usr/share/usb.ids, BSD option of a dual licence; cached, not fetched per build.
- [USB and xHCI](schunix-usb-xhci.md) — enumeration and control transfers work; written from the spec, because porting BSD's stack means porting newbus first.
- [Licensing](schunix-licensing.md) — Apache-2.0, copyright Nexoniarz; BSD drivers are usable and Linux ones are not.
- [Layout migration](schunix-layout-migration.md) — the bt/ → ports/ + lib/ + cmd/ restructure and the two Makefile traps it exposed.
- [Verify by driving the shell](schunix-verify-by-driving-shell.md) — booting to a prompt proves nothing; run real commands over serial with paced input.
- [/dev and /proc](schunix-devfs-procfs.md) — how both are built; ps and top work now, one cosmetic gap left.
- [TTY foreground pgrp coupling](schunix-tty-fg-pgrp-coupling.md) — TIOCGPGRP and getpgrp() must agree or the shell spins forever; how to spot that hang.
- [Sockets and evdev](schunix-sockets-and-evdev.md) — AF_UNIX with one table lock, /dev/input/eventN, and why keycodes need a table for USB but not PS/2.
- [The removed acceptance test](schunix-doom-acceptance-test.md) — deleted 2026-08-08 with its trademark references; what it found and the two techniques worth keeping.
- [Sharing preference](nexoniarz-sharing-preference.md) — never push or publish; unfinished work stays local.

View file

@ -0,0 +1,21 @@
---
name: nexoniarz-sharing-preference
description: Does not want unfinished work shared or published anywhere
metadata:
node_type: memory
type: user
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T17:16:58.232Z
---
Strongly does not want to share work that isn't finished. Stated while
explaining an aversion to git ("you need to login to github or gitlab to use
it and this is stupid... I don't like to share stuff that aren't done").
**Why:** it's about publishing, not about version control — the objection was
based on believing git requires a hosted account.
**How to apply:** never push, publish, or otherwise send this work anywhere
without being asked. Local-only git is fine and was accepted once clarified
that `git init` needs no account and uploads nothing — but do not assume that
extends to any remote. Ask before anything leaves the machine.

View file

@ -0,0 +1,42 @@
---
name: schfs-btree-phase1
description: "SchFS phase 1 (the CoW B-tree) is built and passing; run `make schfs-test` after touching it"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-01T20:34:28.236Z
---
The filesystem is called **SchFS** — not "SchFS v2". Design: `docs/architecture/schfs.md`.
**Phase 1 is done** (2026-08-01): the copy-on-write B-tree exists and passes its
torture harness.
- `include/schfs.h` — on-disk format: 4096-byte blocks, 24-byte
`(objectid, type, offset)` keys, node header with a self-pointer, uberblock ring.
- `kernel/fs/schfs/btree.{c,h}` — portable C against an abstract
`struct schfs_bdev`, so the same code runs on a host file and in the kernel.
Not yet linked into the kernel.
- `tools/schfs_test.c` — the harness. **`make schfs-test`** builds and runs it.
Measured geometry: max inline value 1016 bytes, fanout 84 (internal) / 127 (leaf).
**Run `make schfs-test` after any change to btree.c.** It checks against a
reference model, verifies structure, simulates a crash by discarding
uncommitted writes, and confirms both a bit flip and a misdirected write are
detected.
**A test-design trap worth remembering:** the harness originally used
`schfs_key_cmp` inside its own reference model. Deliberately breaking that
function then broke the tree *and* the oracle identically, and every check
still passed — the suite could not fail. The model now has its own
`ref_key_cmp` written out longhand. Mutation-test any new oracle the same way
before trusting it.
Known and deliberate: delete does not merge or rebalance underfull nodes. The
tree stays correct and sorted; merging is phase 3.
Next: phase 2 (read-only mount of a v2 image), then writes/transaction groups,
then extents. Per-pid `/proc` for ps/top should ride along with that VFS work —
see [[schunix-devfs-procfs]].

View file

@ -0,0 +1,45 @@
---
name: schfs-phase2-mount
description: "SchFS phase 2 is done — the kernel mounts real images read-only; run `make schfs-check` after touching kernel/fs/schfs"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-03T12:37:43.088Z
---
**Phase 2 done (2026-08-02):** the kernel mounts a SchFS volume read-only at
boot and it is verified end to end. Phase 1 notes: [[schfs-btree-phase1]].
Layout — everything but `kmount.c` compiles into *both* the kernel and the host
tools, so writer and reader cannot drift:
- `kernel/fs/schfs/vol.c` uberblock ring, `obj.c` read side, `objw.c` write side
(host only until phase 3), `kmount.c` buffer-cache bdev + boot mount.
- `tools/mkschfs.c` builds images, `tools/schfs_fsck.c` verifies/lists/extracts.
**Run `make schfs-check`** (B-tree + round-trip) after any change under
`kernel/fs/schfs`. `tools/mutate.sh` injects six bugs and fails if any survives.
Boot-testing: `make ramroot` first (root from RAM leaves device 1 free), then
`make qemu-schfs`, then `cat /proc/schfs`. Unattended:
`tools\bootcheck.ps1 -SchFS` drives the shell over serial and asserts on the
transcript — see [[schunix-verify-by-driving-shell]].
**Traps that cost real time, all fixed:**
- The root's object id must be *reserved*; starting `next_objectid` at it made
the first directory become the root, so it contained itself.
- Range callbacks get pointers into the cursor's block buffer, which any nested
lookup overwrites. Directory iteration copies each entry out first.
- A 4096-byte block will not fit on the 4096-byte kernel stack (KSTACKSIZE).
The kstack canary catches it as "kstack overflow".
- Extents only merge if the whole run is allocated before any tree write.
Full write-up, including why the design doc's phase list changed, is in
`docs/architecture/schfs.md` §8-9.
**Superseded on 2026-08-03 by [[schfs-phase3-writes]]** — writes are done, and
free space is *derived from the tree at mount*, not tracked as tree keys as
planned here. The VFS work is still outstanding, and also unblocks per-pid
`/proc` for ps/top ([[schunix-devfs-procfs]]).

View file

@ -0,0 +1,194 @@
---
name: schfs-phase3-writes
description: "SchFS phase 3 is done — full write API, in-RAM allocation map derived from the tree; the only gap left is VFS integration"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-03T13:59:08.496Z
---
**Phase 3 done (2026-08-03), including VFS integration.** SchFS is a complete,
mountable, read-write filesystem. Follows [[schfs-phase2-mount]].
```
mkdir /mnt && mount -t schfs /dev/hdb /mnt
```
then ordinary `ls`/`cat`/`>`/`>>`/`mkdir`/`cp`/`rm`/`rmdir` all work on it.
**The VFS now has a real dispatch layer**: `struct fs_ops` in `include/file.h`,
one table per filesystem, chosen by `iget()` from the device number and hung off
the inode. v1's implementations kept their bodies and are named `v1_*`; SchFS's
live in `kernel/fs/schfs/vfs.c`. **Adding a filesystem is a new table, not
another branch in fifteen functions.** `getdents64` is an fs_op for the same
reason — v1's `struct dirent` (16-bit inum, 14-byte name) cannot represent a
SchFS entry, so each filesystem emits the record stream itself.
Mounting is a substitution during path resolution (`mount_add`/`mount_cross` in
`fs.c`), recorded nowhere on disk. `..` at a mounted root has to be redirected
out explicitly, or the mount becomes a subtree you can enter and never leave.
**Two design decisions that contradict the older design doc — the doc is now
right, older notes are not:**
- *Free space is NOT tracked as tree keys.* Allocating from a tree-resident map
mutates the tree, which allocates, which changes the map. Instead the map is
**derived** at mount by walking the tree (`kernel/fs/schfs/space.c`) and lives
only in RAM. It cannot disagree with the filesystem because it is computed
from it. This is what caps a mounted volume at 64 GiB (`SCHFS_SPACE_PAGES`).
- *Data blocks carry a generation too* (`schfs_extent.gen`, 64-bit). Same rule
as tree nodes: a run stamped with the group being built may be overwritten in
place. Without it every 100-byte write copied a 4 KB block and split an
extent.
**Testing.** `make schfs-check` now runs three suites — add `schfs-objtest`
(`tools/schfs_objtest.c`, memory-backed, covers files/dirs/space accounting) to
the two that existed. `sh tools/mutate.sh` injects nine bugs; all nine must be
caught.
**The two tests that actually earn their keep**, both found by mutation testing
after everything else passed:
- Filling and emptying the volume *three times on one mount* and checking each
round stores the same amount. A leaked bitmap heals on remount, so a
remount-based check cannot see it; only spending the space again can.
- Overwriting a block **after a commit**. Within one transaction group both the
tree and the data rewrite in place, so a test that never commits never runs
the copy-on-write path at all.
`struct schfs_vol` is **not copyable** — `tree.dev` points at its own `dev`
field. Open into the storage it will live in.
## Migrating root off the xv6 legacy FS (2026-08-09)
Nexoniarz: kill the legacy FS, keep SchFS ("SchFS is goat"). Agreed — and it is
a **migration, not a rewrite**: SchFS is 4,204 lines across 7 files against
`kernel/fs/fs.c`'s 1,830, and it already builds images, mounts, and dispatches
through `struct fs_ops`. Every xv6 legacy problem (DIRSIZ 14, the 16-byte
dirent, the HTree byte layout, `ushort inum`) lives below a filesystem that has
already been replaced but never promoted.
**The one line holding xv6 in charge:**
kernel/fs/fs.c:1642: ip = (*spath == '/') ? iget(ROOTDEV, ROOTINO) : idup(start);
**Blocker 1, now cleared: `mkschfs` would not build on Windows.** It needed
`sys/sysmacros.h` (major/minor) and `lstat`/`readlink`/`S_ISLNK`, none of which
mingw has. Stubbed under `__MINGW32__` — honest here because Windows has no
mknod and `find sysroot -type l` returns nothing, so neither device nodes nor
symlinks can appear in the tree being imaged. **If symlinks are ever staged**
(procfs wants `/proc/<pid>/exe` to be one) this host can no longer build a
faithful image and the stub must become a real reparse-point read.
`make build/schfs.img` now works: 586 of 8000 blocks, 2344 KB, generation 2.
**Blocker 2, confirmed and not started:** booting with schfs.img as the root
disk panics `fsinit: invalid file system` — v1 `fsinit()` probes ROOTDEV for a
v1 superblock and halts. So the order is:
1. `fsinit`/`namei` reach the root through `fs_ops` instead of
`iget(ROOTDEV, ROOTINO)`.
2. A `ramroot`-equivalent SchFS image, so root can be swapped without a disk.
3. Point `ROOTDEV` at the SchFS volume; keep v1 mountable until it is proven.
4. Delete `fs.c`.
**Do this with a fresh session.** Swapping the root filesystem is the change
most able to leave an unbootable image, and step 1 wants room to boot, fail and
retry.
### Sizing step 1 (2026-08-09) -- it is small
`ops_for(dev)` in `kernel/fs/fs.c:278` already dispatches the whole VFS:
if (dev == SCHFS_DEV) return &schfs_ops;
if (dev == PROCFS_DEV) return &procfs_ops;
return &v1_ops; // legacy, only because it is the default
`struct fs_ops` (include/file.h:35) is a complete vtable -- iload, istore,
ifree, read, write, truncate, dirops -- and **SchFS implements all of it**. So
`fs.c` is no longer "the filesystem"; it is the fallback branch. Killing it is
three edits, not a rewrite:
1. `fsinit()` stops requiring a v1 superblock on ROOTDEV; call `schfs_kmount()`.
2. `ROOTDEV` becomes `SCHFS_DEV` so `ops_for` returns `schfs_ops` for `/`.
3. `namei`'s `iget(ROOTDEV, ROOTINO)` uses `schfs_kroot()` for the root inode.
Then `v1_ops` has no caller and `fs.c` can go. Keep a build flag or a second
disk to boot v1 until SchFS has carried `/` through a full bootcheck run.
Nexoniarz calls the legacy one "ShitFS v1" -- worth knowing that "SchFS v1" in
older notes means the *xv6-derived* filesystem, not early SchFS.
### The recycle bin, removed 2026-08-09
`sys_delete` (syscall 51, NO_LINUX) set an on-disk inode flag `showmode = 0`,
and `fileread`/`filewrite` then refused every operation with a printf. A
"deleted" file still existed, still had its name, still held its blocks, and
answered read() with -1 -- and since nothing was freed, deleting filled the disk
faster. Both gates removed; the syscall entry and the on-disk field stay only
because pulling them touches the inode layout, and both die with fs.c.
### SchFS is the root filesystem -- default since 2026-08-09
`make` builds it, `make qemu` boots it, `tools/bootcheck.ps1` tests it. There is
no opt-in switch any more: `schfsroot`, `-DSCHFS_ROOT` and the `-SchFS` /
`-SchFSRoot` bootcheck switches are all gone. schunix.img depends on
`build/schfs.img`, and a kernel handed anything else on device 1 panics in
forkret().
Full suite green -- base, -Tty, -Ps, -Sock, -Fb, -Usb, -Evdev.
Three edits carried `/` over:
1. `kernel/proc/proc.c` forkret: `schfs_kmount(ROOTDEV)` + `schfs_kmounted()`
check, replacing `fsinit(ROOTDEV)`. Mounted there and not in main() because
mounting reads blocks, bread() sleeps, and sleeping needs a process context.
`schfs_kmount` returns **void** -- it reports failure by leaving the volume
unmounted, because elsewhere it is a probe.
2. `kernel/fs/fs.c` namei: root is `iget(SCHFS_DEV, schfs_kroot())`, so
`ops_for()` routes `/` through `schfs_ops`. SCHFS_DEV is 64 -- a synthetic
number for ops dispatch, not the disk the volume lives on.
3. `kernel/fs/log.c`: `begin_op`/`end_op` return immediately, the rest is
`#if 0`. The v1 log made multi-block v1 updates atomic; SchFS gets that from
CoW. They stay as no-ops because sys_fs.c calls them in dozens of places.
**The bug that cost the most, and would cost it again:** `sinit()` sets pid 1's
cwd with `namei("/")` in `main()`, *before* forkret mounts anything. The old v1
root survived that because `iget(ROOTDEV, ROOTINO)` is two compile-time
constants that touch no disk -- the inode fills in lazily at the first ilock.
SchFS reads its root object id off the uberblock, so pid 1 got a cwd built from
an unmounted volume. Absolute paths never consult cwd, so `exec("/bin/init")`
worked and everything looked healthy; the first *relative* lookup is init's
`open("console")`, which failed, so init had no fd 0/1/2, so every shell it
spawned exited at once and init respawned it forever. **Silent boot, no
`init: ready`, indistinguishable from a hang.** Fixed by re-pointing
`myproc()->cwd` after the mount.
Also: the kernel execs `/bin/init` (`lib/initcode.S`), not `/init`. Probing the
wrong path made lookup look broken when it was not.
**Still to do -- the v1 format code is compiled but unreachable from `/`.**
`fs.c` cannot simply be deleted: iget/ilock/iput/namei/readi, the whole VFS
layer, live in it too. Only the v1 on-disk format goes -- fsinit, the
superblock, bmap/balloc, `v1_ops`, and the third-party HTree/extent code -- plus
log.c, tools/mkfs.c and build/fs.img. The MIT attribution in NOTICE stays
regardless ([[schunix-licensing]]).
fs.img is still built: the -Usb suite uses it as the USB stick.
### Two harness bugs found doing this, both of which faked OS failures
- **bootcheck slept a flat 4s between commands.** `ls /bin | wc -l` overran it,
so the next command was typed while the shell was busy -- the tty echoed it,
no reader took it, and it landed concatenated onto the following one. The
transcript read `cat /proc/pcicat /proc/schfs` and three PCI checks failed,
looking exactly like a broken PCI driver. Now it waits for the prompt by
polling the logfile QEMU is writing. (It still must not read QEMU's stdout --
that deadlocks, see the script header.)
- **The USB stick was `$hdb`**, so the root volume was attached twice, once as
a disk and once over USB. That failed the storage checks *and* the xHCI
interrupt check. The stick is pinned to `build/fs.img` now and -Usb is green.
**The general lesson, twice over: a red check is not evidence until the harness
is ruled out.** Both of these looked like driver bugs.

View file

@ -0,0 +1,39 @@
---
name: schfs-v2-and-ramdisk
description: The agreed SchFS direction and the ramdisk-root work completed 2026-08-01
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T17:52:30.145Z
---
Agreed plan for Schunix's filesystem and installer, and step 1 is done.
**Order agreed 2026-08-01:** ramdisk root → `struct proc`/exit codes/pgrp →
SchFS → mkfs into `cmd/` → installer + framebuffer GUI.
**SchFS** keeps the name and is designed in `docs/architecture/schfs-v2.md`:
copy-on-write + checksum-in-parent-pointer + uberblock array (ZFS), one CoW
B-tree reused for every key type + extents with refcounts (btrfs), attribute
indexes for the "SQL-like" query feel (BeFS). Explicitly NOT doing: storage
pools/RAID-Z, online format conversion, dedup, or an actual query language —
the useful part of "database filesystem" is the index, not SQL. WinFS is the
cautionary tale; BeFS is the scale model.
**Done — ramdisk root.** `make ramroot` links a full SCHFS image into the
kernel and boots to a shell with no data disk attached (verified). Plain `make`
is unchanged. `kernel/dev/ramdisk.c` + a `bdevrw()` dispatcher in `bio.c`.
**Also done — `tools/mkfs` content dedup**, which is what made the ramdisk
practical: identical files now share one inode with real hard links
(`nlink > 1`). The 57 toybox applet copies collapsed from ~44 MB to one copy —
inodes 73 → 19, used blocks ~47,000 → ~3,400. `mkfs` also gained `--size
<blocks>` so one tool builds both the big root image and the small ramdisk one.
**Why it is opt-in:** stage2 loads the kernel by PIO one sector at a time, so a
6 MB ramroot kernel is visibly slower to boot than the 350 KB default. Use
`make ramroot`, never `make RAMROOT=1` — the flag changes CFLAGS, which make
cannot see, so stale objects would silently keep the wrong `ROOTDEV`.
See [[schunix-rewrite-priorities]], [[schunix-build-environment]].

View file

@ -0,0 +1,58 @@
---
name: schos-cross-platform-build
description: "The build and tools support Windows, Linux, macOS and BSD; clang everywhere, and the four host assumptions that used to break"
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-09T12:26:54.787Z
---
**Nexoniarz develops from both Windows and Linux and switches between them**, so
nothing in the build may assume a host. As of 2026-08-09 all four are supported:
Windows (Git Bash/MSYS), Linux, macOS, and the BSDs. See
[[schunix-build-environment]] for why WSL is not an option on the Windows box.
**One toolchain, not two.** clang is a cross-compiler by construction, so a
single LLVM install produces every target the tree needs — bare-metal ELF, the
16/32-bit code below 1 MB, and Linux/musl userland — from any host. The Makefile
picks clang+lld whenever both are on PATH; `USE_GNU=1` forces the GNU fallback.
The old GNU branch had rotted: it passed `--no-warn-rwx-segments`, which is a
**hard error on lld**, and lld is the default linker on FreeBSD and macOS. It
also wanted a brewed `x86_64-elf-gcc` before a Mac could build at all.
**Host tools use the host's own `cc`.** Only mingw needs naming explicitly, and
only because clang defaults to an MSVC target there while `tools/mkfs.c` needs
`<unistd.h>` and `<dirent.h>`.
**The four things that were actually host-specific**, all fixed — worth
recognising the pattern before adding a fifth:
1. `<sys/sysmacros.h>` is glibc/musl only. macOS and the BSDs have no such
header and take `major()`/`minor()` from `<sys/types.h>`. Guarded on
`__linux__` now.
2. `sha256sum -c -` exists on Linux and Git Bash only. macOS has `shasum -a
256`, FreeBSD/NetBSD `sha256`, OpenBSD `cksum -a sha256`. `tools/sha256check.sh`
tries all five and compares digests itself rather than trusting a `-c` flag,
whose input format also differs.
3. `nproc` is coreutils — absent on macOS and BSD, where it is
`sysctl -n hw.ncpu`.
4. **`Join-Path $root 'build\x'` produces a filename containing a backslash on
a Unix host.** PowerShell 7 runs on Linux/macOS/BSD, so the .ps1 tools are
usable there, but only with forward slashes throughout and QEMU found on
PATH instead of a hardcoded scoop location.
**Do not blanket-replace backslashes in the .ps1 tools.** `bootcheck.ps1` sends
`printf 'caf\303\251'` to the guest to test UTF-8; rewriting those octal escapes
changes what the test asserts without failing it. The sweep is restricted to
known directory prefixes for exactly this reason.
**Ports do not build on Windows** — `make ports` skips with a message and the
tree relies on `sysroot/` being pre-staged. Building musl/toybox/mksh from
source needs a Linux host. That is a real reason to keep the Linux path working,
not a theoretical one.
**Unverified, and say so when it matters:** the Linux, macOS and BSD paths are
written from documented behaviour and have **never been executed** — this
machine has no WSL and no second host. Windows is the only host where the build
and `tools/bootcheck.ps1` have actually been run green.

View file

@ -0,0 +1,46 @@
---
name: schos-memory-sync
description: Project memory lives in the repo at .claude/memory/ and is moved in and out with tools/memory-sync.sh
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-09T12:27:06.629Z
---
**The memory is committed to the repo, at `.claude/memory/`.** Move it with:
```sh
tools/memory-sync.sh status # show both sides, change nothing
tools/memory-sync.sh export # this machine -> .claude/memory/ (then commit)
tools/memory-sync.sh import # .claude/memory/ -> this machine (after clone/pull)
```
**Why it has to be in the repo.** Claude Code stores memory outside the tree at
`<home>/.claude/projects/<key>/memory/`, where `<key>` is the project's absolute
path with every separator *and the drive colon* turned into a dash:
- `D:\DEV\SchOS` → `D--DEV-SchOS`
- `/home/me/dev/SchOS` → `-home-me-dev-SchOS`
Two consequences, both of which have already happened here:
- **Renaming the directory orphans the memory.** `D:\DEV\Schunix` →
`D:\DEV\SchOS` changed the key, and everything under the old one was invisible
until moved across by hand. See [[schos-rename-and-git]].
- **The key is machine-specific**, so memory written on Windows is not where
Linux looks, even for the same checkout. Nexoniarz uses both.
Git carries the memory across renames and between hosts; `import` puts it where
the local machine expects it.
**The colon becomes a dash, it is not dropped.** Deleting it yields
`D-DEV-SchOS`, which matches nothing — that was the first attempt and it
silently found zero memories.
On Git Bash the key must be derived from the **Windows** path (`cygpath -w`),
not the MSYS one: Claude Code is a Windows program and sees `D:\DEV\SchOS`, not
`/d/DEV/SchOS`.
Related: [[nexoniarz-sharing-preference]] still applies — this repo is local,
never pushed anywhere.

View file

@ -0,0 +1,76 @@
---
name: schos-rename-and-git
description: "Schunix is now SchOS, the kernel is SchIFT; the tree is under local git as of 2026-08-09"
metadata:
type: project
---
**On 2026-08-09 the project was renamed from Schunix to SchOS**, to drop the
"-unix" element and any trademark exposure to The Open Group. Nexoniarz's
ecosystem layout:
| Layer | Name |
|---|---|
| Operating system | **SchOS** |
| Kernel | **SchIFT** |
| Desktop environment | SchDE |
| Window manager | SchWM |
| Compositor | SchCom |
| API / subsystem | SchAPI |
| File system | SchFS (unchanged) |
**Every memory written before this date says "Schunix" and is filed under a
`schunix-` slug. That is the old name for this same project -- read it as
SchOS.** They were deliberately not renamed: 42 files and every `[[link]]`
between them, for no gain.
**SchOS vs SchIFT is a real distinction, not a synonym.** Nexoniarz chose the
split explicitly. SchIFT appears *only* where a Unix names its kernel:
- `uname(2)` sysname -> `SchIFT` (nodename is `schos`, it is the host)
- `/proc/version` -> `SchIFT version 0.1.0 (schift) #1 SMP x86_64`
- the two licence sentences that say "kernel" outright, in LICENSE.MIT and
NOTICE
Everything a user reads as the product is SchOS: panic screen, boot menu,
error prefixes, docs, LICENSE, NOTICE. `schunix.img` is now `schos.img`;
`__SCHUNIX__` is `__SCHOS__`. The kernel source stays at `kernel/`, not
`schift/` -- asked and declined, to keep the diff small.
The `Assets/SchOS * Logo.*` files are renamed but the **artwork inside the
Full and Text logos may still spell the old name as vector paths**. That is
design work, not a code change.
## The tree is under git now -- local only
`git init` on 2026-08-09, first commit `d0f7acf` = the tree as it stood before
the rename, second `e04ca28` = the rename. Before this there was **no version
control at all**, which had already cost real damage: a blanket `sed` rewrote
`#include` lines and string literals with no way to undo it.
`core.autocrlf` is **false** and `.gitattributes` pins `* text eol=lf`. This
matters: the build runs under Git Bash, and a Makefile recipe or `.S` file
checked out with CRLF fails in ways that point nowhere near the cause.
`.gitignore` excludes `build/`, `sysroot/`, `dist/` and the images -- `dist/`
alone is 61 MB of upstream tarballs the build re-fetches.
**There is no remote and there must not be one.** See
[[nexoniarz-sharing-preference]] -- the standing rule is that unfinished work
never leaves the machine. Local commits are fine and wanted; `git push` is not.
## The rename broke two things, both the same mistake
Both found by `tools/bootcheck.ps1`, neither visible at compile time:
- `cmd/shmtest/main.c` wrote `"SCHUNIX-MMAP-OK"` with a hardcoded length of
`15` and compared it back by hand-counted offsets (`m[3]=='U'`,
`m[14]=='K'`). The shorter marker made the write run two bytes past the
literal and the compare read past the data -- **failing a file-backed mmap
that was working perfectly.** Now one constant plus `sizeof`.
- bootcheck asserted a 31-byte file after appending to `"hello-from-schunix"`;
the shorter string makes it 29.
**The lesson for any future rename: grep for string literals whose length is
written out separately from the literal.** A compiler cannot catch it and the
failure looks like a broken subsystem.

View file

@ -0,0 +1,61 @@
---
name: schunix-boot-splash-logo
description: "The boot logo pipeline, why the loader draws it rather than the kernel, and the STAGE2_SECTORS trap that consolidating it exposed."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T12:04:49.027Z
---
The boot splash shows the `Assets/Schunix Small Logo.png` mark (an SX monogram in
a ring) above the `S C H U N I X` wordmark and its accent rule, all centred as
one group. Verified rendering correctly on 2026-08-08.
**The logo is drawn by the bootloader, not the kernel.** This is the
counter-intuitive part and it is a timing fact, not a preference: the kernel does
not reach `vga_init()` until roughly the last 300ms of a ~2.8s boot, so a
kernel-drawn logo appeared for about one frame before `console_takeover()` wiped
it. Drawn from `splash()` in `boot/stage2main.c` it is up for the whole boot.
`kernel/dev/vga.c` keeps the same drawing code against the same mask
(`vga_draw_logo`, `vga_logo_centered`) for things that want the mark *after*
boot — the crash screen, eventually `schinit`. Don't "fix" the duplication by
deleting the loader's copy.
**Pipeline:** `tools/mklogo.py` (host Python, has its own PNG inflate +
un-filter) crops to the mark, box-downscales, normalises to full strength, packs
to a 4-bit coverage mask, and writes `include/logo.h`. Run `make logo` — it is
*not* a build dependency, and `include/logo.h` is committed, on the same
reasoning as `include/font.h`. The mask is coverage, not colour, so the palette
stays in [[schunix-console-tty]]'s `include/theme.h`; the loader composites it in
`SCH_FG` because the source art is a silver gradient and the accent is reserved
for the one rule. 4 bits rather than 8 because the header is included by the
*bootloader*, where size is a boot constraint.
**`STAGE2_SECTORS` was encoded in five places, not the three `docs/layout.md`
claimed.** Raising it 24 → 48 to fit the 8 KiB mask proved it: the `dd seek=25`
offset stayed behind and the kernel got written straight over stage2's tail.
The five were `boot/bootasm.S`, `boot/stage2main.c`, the `dd seek=`, and *twice*
inside `tools/mkiso.sh` (its size assertion and `-boot-load-size`). It now lives
only in the root Makefile, which passes `-DSTAGE2_SECTORS` and exports it to
mkiso; the `#define`s left in the boot sources are `#ifndef` fallbacks. Hard
ceiling is 65 sectors — stage2 loads at `0x7E00` and must not reach the
multiboot kernel-search buffer at `0x10000` — and `stage2main.c` now
static-asserts that. See also [[schunix-iso-boot]].
**`tools/splashshot.ps1`** captures a burst of QEMU screendumps across early
boot and reports each frame's distinct-colour count, because the splash window
is too short to hit with a single guessed delay. It uses `-snapshot` plus
`file.locking=off` so it runs while an interactive QEMU has the images open —
booting from copies also works but means duplicating all 153 MB of
`build/fs.img` per run. Colour counts are the
assertion: ~2 means text console or blank, 18 means the 4bpp logo is up. If it
ever reads 156 again, something is drawing an 8-bit mask.
**Two traps, both hit while doing this:**
- `$(shell expr $(N) \* 512)` in a Makefile does not work — make passes the
backslash through, sh globs the `*`, and expr receives a directory listing.
Make silently carries on with nonsense. Use `$$(( ))` in the recipe.
- `make iso` was failing when this was written, for unrelated reasons since
fixed — see [[schunix-ramdisk-root-sizing]]. `-boot-load-size 49` is now
verified: the ISO boots from optical media only.

View file

@ -0,0 +1,54 @@
---
name: schunix-build-environment
description: Schunix builds natively on Windows with clang/lld + mingw + Git Bash; WSL is gone because WSL2 needs Hyper-V
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-07T17:19:34.188Z
---
**Builds natively on Windows. WSL is no longer used at all.** WSL2 runs a real
Linux kernel in a Hyper-V VM, so it hard-requires virtualisation, and enabling
that puts the Hyper-V hypervisor under the whole machine — which demotes VMware
to running nested and slows the box down permanently. Nexoniarz will not enable
it, and that is a reasonable call.
`make` from **Git Bash** does everything. `run.ps1` builds natively too. The
toolchain, all already installed:
- **clang + ld.lld + llvm-objcopy/objdump** (`C:\Program Files\LLVM\bin`) —
clang is a cross-compiler by construction, so `--target=x86_64-elf` builds the
kernel and `--target=i386-elf` the bootloader. No cross-binutils needed.
- **mingw-w64 gcc** (`scoop install mingw`) for host tools — `tools/mkfs.c`
needs `<unistd.h>`/`<dirent.h>`, which the Windows SDK lacks.
- **make** (`scoop install make`), plus Git Bash's perl/sed/dd/sha256sum.
`run.ps1` prepends all three directories itself, so it works from any shell.
The Makefile detects Windows via `uname -s` matching `MINGW*` and sets
`WINHOST`. Ports (musl/mksh/toybox) are **not** rebuilt on Windows — they are
Linux userland built by configure scripts, already staged in `sysroot/`, and
the Makefile says so out loud rather than skipping silently. Everything is a
no-op on Linux, so a Linux/VM build still works identically.
**Five latent bugs the move exposed**, all real, none Windows-specific — see
`docs/architecture/kernel-history.md` §8 for the two hardest:
1. `kernel.ld` named no `.ltext`/`.ldata`/`.lbss` — with `-mcmodel=large`, LLVM
and GCC 13+ put nearly everything there, and orphan placement put them past
`end`, so `kinit1(end,...)` handed the allocator live `.lbss`.
2. `uint64` was `unsigned long` — 64-bit on Linux, **32-bit on Windows** (LLP64).
Host tools include the on-disk structs, so this would have silently written
images with half-width inode fields. Now data-model-aware + `_Static_assert`.
3. `syscall_entry` used `%fs:(0)` as scratch — that is the **TCB self-pointer**.
GCC's direct `%fs:-8` TLS never reads it; clang materialises the TP from it.
4. `_start` read `argc`/`argv` via `register ... asm("rdi")`, which is only
guaranteed for inline-asm operands. clang read uninitialised stack. Now
`lib/start.S`.
5. lld needs `--image-base=0` for anything linked below 0x400000, and
`-m elf_x86_64` on `ld -r -b binary`.
**When a symptom moves as you add printfs, stop printing and use the
watchpoint.** QEMU `-S -gdb tcp::26000` plus mingw's gdb (`hbreak`, then
`watch`) named the offending instruction on the first run after three rounds of
cprintf had failed. See [[schunix-verify-by-driving-shell]].

View file

@ -0,0 +1,50 @@
---
name: schunix-build-speed
description: The build is fast now (no-op ~2s); the four things that made it slow and the traps that will make it slow again
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T17:27:56.099Z
---
**A no-op `make` went from 1m48s to ~2s on 2026-08-04**, and a full kernel
rebuild from ~110s to ~7s. Almost none of it was compilation — with only 2.4s
of CPU in that 1m48, it was I/O across WSL's `/mnt/d` boundary. Four causes,
all still worth not reintroducing:
- **`tools/mkfs` built the image on the file**, one `lseek` + one 512-byte
`write` per block: 300,000 syscall pairs, 150,000 of them just to zero it.
It now assembles the image in RAM and writes once. It also stops at the last
non-zero block and `ftruncate`s, so ~2 MB is written instead of 147 MB.
- **The image was rebuilt on every invocation.** `sysroot-install` is phony, so
everything depending on it always ran. There is now a `build/sysroot.stamp`
whose mtime only advances when a `find`-based summary of the staged tree
actually changes (the `cmp -s ... || mv` idiom — **do not add a `touch`, and
note `touch -c` still updates an existing file's mtime**, which is exactly
the bug I wrote and had to remove).
- **`ports/toybox` re-linked its 236 applets every build**, because port.mk
declares `install` .PHONY for every port. It hangs off
`$(BUILDDIR)/toybox.installed` now. This was the sneaky one: it bumped the
mtime of 236 files in `sysroot/` and so invalidated the stamp above.
- **mkfs and mkschfs re-read every file to dedup it.** They now check
`(st_dev, st_ino)` first — toybox's applets are real hard links, so 236 of
them are settled by one `fstat` instead of ~500 MB of reads.
Two real bugs found in the Makefile while doing this:
- **`$(BUILD)/kernel/**/*.d` only matched one directory level.** make's wildcard
has no recursive `**`. `arch/x86_64/` and `fs/schfs/` therefore had **no
header dependency tracking at all** — editing `include/schfs.h` left stale
objects that linked fine. It uses `$(shell find ...)` now.
- **`CFLAGS` is recursively expanded and had a `$(shell)` appended**, so the
stack-protector probe re-ran on every expansion: one extra gcc per object
file. Probed once into `NO_SSP :=` now.
`schunix.img` is no longer `.PHONY` (it is a real file), and `MAKEFLAGS +=
-j$(nproc)` is on by default — use `make -j1` for readable output from a
failing build.
Verify with `make schfs-check` plus [[schunix-verify-by-driving-shell]]; the
superblock checksum is printed by mkfs and should not change when only build
mechanics change.

View file

@ -0,0 +1,67 @@
---
name: schunix-console-tty
description: "The console has an ANSI parser, real termios and UTF-8; the font is CP437 not Latin-1, and five bugs that looked like broken commands"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T21:10:44.835Z
---
**`kernel/core/console.c` was rewritten on 2026-08-04**: ANSI escape parser,
real termios, signals, and UTF-8.
**The serial line and the framebuffer are treated differently on purpose.**
Escapes are passed through to serial verbatim -- a real terminal emulator is
on the other end and understands them better than we ever will -- and
*interpreted* only for the framebuffer. So a serial transcript can never tell
you whether the escape parser works; it holds the raw bytes either way. Use
`pwsh -File tools/ttyshot.ps1`, which drives colour/cursor/erase/UTF-8 and
screendumps the result.
**The font is CP437, not Latin-1.** `include/font.h`'s per-glyph comments
label index N with Unicode codepoint N, which is fiction above 127: index 0xE9
is annotated "e-acute" and draws a capital theta. Trusting it turned "café"
into "caf" + theta. `console.c` maps Unicode through `latin1_to_cp437[]` plus
a small table of box-drawing characters. **Verify glyphs by rendering the
bitmap, never by reading the comment.**
`font_data` was also declared `[128][16]` while containing 256 glyphs, so the
compiler silently discarded the entire upper half -- the wall of "excess
elements in array initializer" warnings was that.
**Five bugs that all presented as broken commands, none of which were:**
- `ls /bin | wc -l` said 48 instead of 240 -- `sys_ioctl` answered *every*
unrecognised request with 0, so `TIOCGWINSZ` on a pipe "succeeded" and every
program thought pipes were terminals. It returns `-ENOTTY` now. isatty() is
built on this call, so the blast radius was everything.
- `yes | head -2` failed with "Invalid argument" -- `readv`/`writev` capped
iovcnt at 16 (POSIX's minimum) and toybox's `yes` builds 256. Now
`UIO_MAXIOV` = 1024, Linux's value.
- `cat big | head -c 4` killed the shell at `rip 0x1` -- **SIG_IGN is handler
value 1**, and `deliver_signals()` only skipped 0 (SIG_DFL), so it jumped to
address 1. Nothing sent SIGPIPE until pipes learned to, at which point every
shell that ignores SIGPIPE died.
- Pipes returned a bare `-1` with no signal on a closed read end; now SIGPIPE
plus `-EPIPE`.
- Ctrl-C did `proc->killed = 1`, and `proc` in an interrupt handler is
whatever was running on that CPU -- almost never the foreground job. It
sends a real SIGINT to `tty_fg_pgrp` now.
**Control characters must never reach the glyph path.** `vga_putc` had no tab
case, so `'\t'` fell through to `vga_draw_char(9)` -- CP437 glyph 9 is a
hollow circle, so pressing Tab printed a circle. The font has a picture for
every control code and none of them is what the character means. Tab now
advances to the next eight-column stop (erasing as it goes, so it overwrites
rather than skipping); everything else below 0x20 is dropped.
termios is real: TCGETS returns what was last set, TCSETS is honoured, and the
console implements ICANON, ECHO, ECHOE, ECHONL, ISIG, ICRNL, INLCR, IGNCR,
ISTRIP and VMIN. Raw mode returns bytes as they arrive rather than waiting for
a newline -- that is what zsh and any line editor need. EOF is a flag, not a
`^D` byte stuffed into the buffer.
Check with `pwsh -File tools/bootcheck.ps1 -Tty` (six assertions, including a
real Ctrl-C against a running `sleep`). See
[[schunix-verify-by-driving-shell]] and [[schunix-tty-fg-pgrp-coupling]].

View file

@ -0,0 +1,57 @@
---
name: schunix-crash-screen
description: "PSOGM crash screen lives in kernel/core/panic.c; test it with `echo c > /proc/sysrq` and tools/crashshot.ps1"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-03T15:09:42.189Z
---
**The crash screen is `kernel/core/panic.c`** (rewritten 2026-08-03; it used to
live inside `console.c`). **PSOGM — Purple Screen of Guru Meditation.** Solid
purple, big white `:(` drawn from primitives, Windows-style left-aligned
headline and field table, then a raw call-chain dump. Nexoniarz picked this
after a first attempt that dimmed the desktop and floated a dark panel with a
cartoon emoji over it — **the emoji and the muted palette read as out of
place**; the name says purple screen, so it is one.
A "Stop code" field is derived from the panic message (uppercased up to the
first colon: `"sysrq: crash requested"` → `SYSRQ`), so no call site has to
supply one.
**To see it, crash on purpose:**
```
echo c > /proc/sysrq # from inside Schunix
```
**To photograph it:** `pwsh -File tools/crashshot.ps1` (or `make crashshot`)
after `make ramroot`. It boots, fills the screen with real output, crashes,
and pulls a PPM out through QEMU's monitor on TCP 55555. Convert with
`convert build/crashscreen.ppm build/crashscreen.png` — ImageMagick is in the
WSL Fedora box as `convert`, not `magick`.
Why the trigger exists at all: **the crash screen is the one part of the system
you cannot look at by using the system**, so without a deliberate trigger it is
only ever seen when something is already wrong, and it quietly rots.
**Three things that will bite anyone editing it:**
- There is no libm. `isqrt` in panic.c uses Newton's method iterated *while the
estimate decreases*; the obvious "stop when it stops changing" version does
not terminate, because on a non-square it settles into a two-cycle
(`isqrt(3)` alternates 1, 2, 1, 2...). That shipped once and hung the panic
handler with the screen half drawn — a hang with no way to find out why.
- `vga_draw_char` fills the *background* of every glyph with `vga_bg`. Drawing
panel text without setting `vga_set_bg(PSOGM_PANEL)` first puts every line in
a cream-coloured box.
- `cprintf` writes to the framebuffer as well as the serial line, so the serial
report scrolls straight over the panel. `cons_panic_mode()` sets
`cons_serial_only`, which is what keeps the two apart — paint order alone
does not.
The serial copy is the one that survives, and it is what `tools/bootcheck.ps1`
reads. It is also all you get when the panic happens before there is a
framebuffer. See [[schunix-verify-by-driving-shell]].

View file

@ -0,0 +1,49 @@
---
name: schunix-devfs-procfs
description: "How /dev and /proc work in Schunix; ps and top work as of 2026-08-08"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-08T16:24:48.813Z
---
**/dev** nodes are created by `cmd/init/main.c` with `mknod()` at every boot,
and served by `kernel/dev/cdev.c`'s `devsw[major]` table. Minor numbers under
major 1 follow Linux's mem-major layout where free: null=2, zero=5, full=7,
random=8, urandom=9. `/dev/hole` (minor 4) was deleted 2026-08-01 — an
abandoned experiment nothing referenced.
**/proc** (`kernel/dev/procfs.c`, major 6) synthesises eight global files:
meminfo, uptime, cpuinfo, version, stat, loadavg, mounts, filesystems. Each is
a device node with its own minor; reading renders the whole text and returns
the slice at the caller's offset.
**Two gotchas that shaped it:**
1. `devsw[].read()` used to be called as `read(ip, 1, addr, n)` — every caller
passed the literal `1` where an offset belongs, so a device could never
know where a read started. Fine for a stream like the console, fatal for a
/proc file: without an offset every read returns the whole text again and
`cat` never terminates. `kernel/fs/file.c` now passes `f->off` and advances
it by the amount returned.
2. `/proc/mounts` must list `/` **last**. df keeps the last mount point that
prefixes the path it was asked about, so listing root first made `df /`
report /proc's numbers.
**Per-process `/proc/<pid>/` directories now exist and `ps`/`top` work** —
re-verified 2026-08-08 with `tools/bootcheck.ps1 -Ps`: ps finds init and the
shell, `-o ppid` shows the real parent, top reads `/proc/meminfo`. This entry
used to say they were impossible without a VFS rewrite; that was overtaken by
`kernel/fs/procfs.c`, which implements /proc as a real filesystem behind
`struct fs_ops` with synthetic inodes (`pid<<8` encoding) rather than touching
`fs.c`. See [[schfs-phase3-writes]] for the `fs_ops` dispatch.
One cosmetic gap remains: `ps -o comm` shows `[init]` in brackets, because
toybox `readlink()`s `/proc/<pid>/exe` and ours is a regular file, not a
symlink. Needs symlink support in procfs.
`sysinfo(2)` also reports real memory now (`PHYSTOP` and
`kalloc_free_bytes()`); it used to return hardcoded 256MB/128MB, which is what
`free` reads.

View file

@ -0,0 +1,92 @@
---
name: schunix-device-model
description: "The bus-agnostic device model behind /proc/devices, and the settled decision on how Linux drivers may be used."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T18:05:13.551Z
---
Built 2026-08-08. `include/device.h` + `kernel/dev/device.c`, readable at
`/proc/devices`. Written up in `docs/drivers.md`. Supersedes the two separate
arrays in `kernel/drivers/registry.c` as the answer to "what is attached".
**Bus is provenance, class is capability** — keep them separate. `bus` is
`platform`/`pci`/`usb`; `class` is `display`/`input`/`storage`/`net`/`bus`. A USB
disk and a SATA disk are both `storage`. `device_find_class()` is how a consumer
asks for "the display" without naming a driver, which is what stops the next
display driver requiring edits to every consumer.
Where a driver claims a device, **the driver's opinion beats the descriptor's**:
`struct usb_driver` gained a `devclass` field distinct from the USB interface
class, because the descriptor says what the device claims to be and the driver
knows what it made of it. A USB driver that forgets to set it lands in `other`.
Registration is automatic in `pci_bind()` and `usb_bind_all()` so a driver
cannot forget; platform devices are registered by hand in
`platform_register_devices()` in registry.c, in one readable block, matching
that file's existing "a list you can read beats a list assembled by side
effects" reasoning.
Flat list with a bus tag, **not a device tree** — nothing walks a hierarchy yet.
No lock: everything is added during single-threaded boot. **USB hotplug would
break both assumptions** and needs a spinlock plus a reader lock in procfs.
## The display class
`include/display.h` + ops in `kernel/dev/vga.c`, published on the device by
`vga_pci_driver.ops`. `struct pci_driver` gained an `ops` field for this —
**it has to live on the driver, not be set inside probe()**, because
`pci_bind()` registers the device *after* probe returns.
Ops: `mode`, `acquire`, `present(x,y,w,h)`, `wait_vblank`. The wrappers in
device.c null-check every op so consumers are not each written around a
maybe-absent vtable.
- **Back buffer comes from `dma_alloc()`, which is the wrong pool** and is used
because it is the only *physically contiguous* allocator in the kernel —
kalloc hands out single pages. 1.9 MB of a 4 MB arena meant for device rings.
Allocated lazily on first `acquire()`, never at boot. The real fix travels
with the E820 work ([[schunix-smp-and-memory-bugs]]).
- **vblank is the 1987 VGA port 0x3DA bit 3**, not VBE — VBE's protected-mode
interface for this is optional and usually absent. Two loops (wait for the
current retrace to *end*, then for the next to begin), or you return
immediately having just missed one. Both loops are bounded so hardware that
never reports retrace cannot hang a caller holding the console lock.
`echo d > /proc/sysrq` runs `display_selftest()`; `tools/dispshot.ps1`
photographs it to `build/dispshot.ppm` — it was derived from crashshot.ps1 and
originally kept its output path, so the two silently overwrote each other and
`crashqr.py` then failed on a capture that was not a crash screen. The pattern is designed to fail visibly: quadrants slant on a
stride error, change colour on a bpp error, and the 1px inset frame clips on an
off-by-one in the present rectangle. Verified 2026-08-08.
The console still writes *directly* to the framebuffer; the two paths coexist
deliberately. Routing text through the back buffer would mean presenting per
character.
## Linux drivers — settled 2026-08-08
Nexoniarz's decision, and the reasoning is correct: **not shipped, ever**; the
user installs one themselves through a device manager and Schunix loads it.
- *Distribution* is what triggers the GPL. A user obtaining and loading a driver
on their own machine is their act, not ours.
- Implementing a *loader* from published documentation is clean-room and fine —
it stays clean-room only if written from docs, not transcribed from Linux
source. See [[schunix-licensing]].
Also worth keeping separate when this resumes: "loadable modules" (a stable ABI
plus a linker/loader, on top of this model) and "drivers out of kernel"
(user-space processes, DriverKit-style — needs IPC and mmap, both of which
already exist). They get bundled together in conversation and are different
projects.
## Adding /proc files
Three places, all needed: a `PROC_*` id and a case in `proc_gen_global()` in
`kernel/dev/procfs.c`, and a row in the `globals[]` table in
`kernel/fs/procfs.c`. Miss the third and the file exists but cannot be opened by
name. `sb_pad()` exists there now for column layout — cprintf has no width
specifier.

View file

@ -0,0 +1,48 @@
---
name: schunix-dirsiz-14
description: DIRSIZ was 14 and truncated silently; raised to 30 on 2026-08-09 by holding the HTree bucket at 256 bytes.
metadata:
type: project
---
Found 2026-08-09 while staging fonts. `DIRSIZ` in `include/fs.h` is **14**, an
xv6 inheritance, and the v1 filesystem truncates to it **silently**.
`JetBrainsMono-Regular.ttf` was stored as `JetBrainsMono-`. Two consequences,
the second much worse than the first:
- The file cannot be opened by its real name, and `stat` fails on it — `ls -l`
showed `?` for every field, which is what tipped this off.
- **`JetBrainsMono-Regular.ttf` and `JetBrainsMono-Bold.ttf` both truncate to
`JetBrainsMono-`.** Any set of files sharing a 14-character prefix collides.
That is silent data loss, not a display limitation.
## Fixed the same day: DIRSIZ is 30
**The trick that made it safe: hold the HTree bucket at 256 bytes.**
The directory hash tree is laid out in *bytes*, not entries. A bucket was 16
slots x 16 bytes = 256, and `HASHRANGE`, `OVERFLOW_DIR_START` (113 * BSIZE),
`OVERFLOW_DIR_END` and `MAXFSIZE` are all derived from that. Raising DIRSIZ
naively to 62 would have made buckets 1024 bytes and pushed the primary area
from 117 KB to 470 KB, straight through the overflow region -- silent corruption
of every directory.
DIRSIZ 30 makes `struct dirent` exactly **32 bytes**, so a 256-byte bucket holds
**8** slots instead of 16 and **every byte offset in the layout is unchanged**.
`DIRENT_BUCKET_BYTES` in `include/fs.h` is now the constant the maths is written
against; `kernel/fs/fs.c` and `tools/mkfs.c` both use it instead of
`16 * sizeof(struct dirent)`. Cost: more overflow use in heavily collided
directories.
**Verified in the running system**: `/usr/share/fonts` lists
`JetBrainsMono-Regular.ttf` in full, and `/tmp/JetBrainsMono-Regular.txt` and
`/tmp/JetBrainsMono-Bold.txt` coexist with distinct contents -- the exact case
that used to collapse into one entry. All four bootcheck modes pass.
**Still not POSIX's 255.** Getting there means reshaping the directory format,
which wants the on-disk version policy SchFS is getting first. 30 covers real
filenames; it is not a general solution.
`sysroot/bin`'s 244 toybox links were checked for collisions at the old limit:
there were none, so nothing was silently broken before this.

View file

@ -0,0 +1,46 @@
---
name: schunix-doom-acceptance-test
description: "The full-stack acceptance test (a ported 1993 shooter) was removed on 2026-08-08 — what it found, why it went, and what to do instead."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T12:19:38.682Z
---
**Removed 2026-08-08 at Nexoniarz's request** ("we don't need it anymore because
we know that doom works"). Do not re-add it, and do not reintroduce the name into
comments — they also asked for trademark words to be cleared from the tree, and
it is now at zero references outside `dist/`.
Deleted: `ports/fbdoom/`, `tools/doomtest.ps1`, `tools/doomtest2.ps1`, the
`doom`/`doomtest`/`doom-uninstall` Makefile targets, `NOTPORTS := fbdoom`.
**The WADs were moved, not deleted** — `dist/wads/` — because `doom1.wad` was a
shareware download that existed nowhere else in the tree, while `freedoom1.wad`
is recoverable from `dist/freedoom-0.13.0.zip`.
**What it was worth, because the shape is worth repeating.** A large program
somebody else wrote, exercising `/dev/fb0`, evdev, `mmap`, the ELF loader,
`gettimeofday` and a fixed-rate tic loop at once, is harsher than any in-house
test *because it was not written against our bugs*. It found: the
`fcntl(F_SETFL, O_NONBLOCK)` no-op; the evdev backlog replayed to each new
opener; and the missing `EVIOCGRAB`. All fixed, all now covered by
`tools/bootcheck.ps1 -Evdev` / `-Sock` — which are narrower and do **not** prove
the whole stack composes. That is the honest cost of the removal.
**Why it went beyond being asked:** ~100k lines of GPL-2.0 engine on
non-redistributable shareware data, whose safety depended on two separate
opt-outs (`NOTPORTS` and `RAMDISK_EXCLUDE`) both staying correct — and the
ramdisk is linked *into the kernel binary*, so a slip there makes the kernel
undistributable, not just the image. See [[schunix-ramdisk-root-sizing]] and
[[schunix-licensing]].
**Two techniques from it that transfer:**
- Assert on *pixels*, not log lines. A log-only harness once passed on a blank
screen. `tools/splashshot.ps1` counts distinct colours for this reason.
- For a crash inside a program you did not write, run `llvm-nm` against the
faulting `rip` to get a function name, then use the fault offset to identify
the exact field. "Null pointer plus 24" alone is nearly useless.
Both lessons are written up in `docs/roadmap.md` Phase 7 and
`docs/architecture/kernel-history.md` §10 and §12, with the program genericised.

View file

@ -0,0 +1,60 @@
---
name: schunix-driver-infrastructure
description: "Dynamic IRQ registration, a DMA arena and PCI capabilities exist now; PCI interrupts are level/active-low and the old ioapicenable only did edge/high"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T22:21:01.983Z
---
**Built 2026-08-04**, before USB, because xHCI, any NIC and virtio-gpu all
need the same three things and none of them existed.
- **`kernel/dev/irq.c` + `include/irq.h`** — `irq_register(irq, fn, arg,
name, level_low)`. `trap.c` used to be a switch listing every interrupt by
name, which cannot work for a PCI device: its line comes out of config
space at probe time, so there is no constant to write a `case` label for.
Chains of up to 4 handlers per line, because PCI INTx pins are shared and a
handler must recognise that its own device is idle and return.
Registration publishes an entry then bumps `n` with a barrier, so dispatch
needs no lock. `/proc/interrupts` (minor 12) shows counts and trigger mode.
- **`kernel/core/dma.c` + `include/dma.h`** — `dma_alloc(size, align)` /
`dma_free(p, size)` (Linux's contract: caller remembers the size). Carved
off the top of physical memory in `main.c` *before* `kinit1`, because
kalloc's free list makes no contiguity promise. x86 PCI is cache coherent
so no PAT games are needed — but ordering is still your problem.
- **`pci_find_cap()` and `pci_msi_enable()`** in `kernel/dev/pci.c`.
**Four traps worth remembering:**
1. **PCI interrupts are level-triggered, active low.** `ioapicenable()`
hardcoded edge/active-high, which is right for every ISA-era device and
wrong for every PCI one — and it fails *silently*: you get exactly one
interrupt, then the device holds the line asserted, no further edge ever
comes, and it is never heard from again. `ioapicroute(irq, cpu,
level_low)` is the real call now.
2. **`IRQ_ERROR` (19) and `IRQ_SPURIOUS` (31) are LAPIC vectors that xv6
numbered as if they were IOAPIC pins**, so `T_IRQ0+19` is already taken. A
PCI device routed to pin 19 would collide. `irq_register` refuses them.
3. **`cprintf` has no field widths.** `%04x` is not a directive to it: it
prints `%` and `0` literally, consumes no argument, and every argument
after it lands in the wrong place. It understands only
`%d %u %l %x %p %b %s %%`.
4. **Unaligned MMIO reads return garbage.** Reading a uint32 at BAR offset 2
to get xHCI's HCIVERSION returned zero. Read the aligned dword and shift.
5. **Never let a device raise an interrupt before its line is claimed.** This
one cost real time. xHCI transfers completed, completion events sat in the
event ring, `USBSTS.EINT` and `IMAN.IP` were both set -- and the interrupt
count stayed at zero. Cause: the controller asserted INTx during
enumeration, while the IOAPIC entry was still masked. **An interrupt that
arrives at a masked pin is dropped, not deferred** (the 82093AA manual says
so, and QEMU implements it), and INTx is a *level* -- so the line then sits
high forever with no further transition for anyone to notice. Unmasking
afterwards finds nothing pending. The fix is ordering: enumerate with
controller interrupts off, then `irq_register`, then clear the device's
pending status so it drives the line low, and only then enable. The next
event is a genuine low-to-high transition.
Verify with `pwsh -File tools/bootcheck.ps1 -Usb`. See [[schunix-usb-xhci]],
[[schunix-pci-drivers]].

View file

@ -0,0 +1,114 @@
---
name: schunix-e1000-and-driver-layout
description: "The e1000 NIC driver, the drivers/ reorganisation by device class, and virtio-gpu as the QEMU default."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T20:17:14.134Z
---
2026-08-08. Three changes, all requested by Nexoniarz in one go.
## drivers/<class>/<driver>/
kernel/drivers/
registry.c the one list of what exists
gpu/vga/ gpu/virtio_gpu/
net/e1000/
bus/xhci/
usb/core/ usb/hid/ usb/storage/ usb/hub/
One directory per *driver*, not per class (Nexoniarz asked for this
specifically), so a driver that grows a register header, a second source file or
a firmware blob has somewhere to put it.
Matches `DEVCLASS_*` in `include/device.h`. Flat was eight files with four
called `usb_something`, and each driver added made it worse.
**Makefile note:** the compile rule is now
`@mkdir -p $(dir $@)`, not a fixed path — a hardcoded mkdir only creates the top
directory and every subdirectory build fails with "unable to open output file".
`KERN_DRIVERS` is built with `+=` per class rather than one long
`$(addprefix ...)`; scripted edits kept collapsing the line continuations.
## virtio-gpu is the QEMU default
`QEMU_DISPLAY = -vga std -device virtio-gpu-pci` in the Makefile, used by all
three qemu targets. **Keep `-vga std`** — the bootloader draws the splash
through VBE in 16-bit real mode and there is no virtio path that early, so a
machine with only virtio-gpu boots blind until the kernel is up.
## FreeBSD reference tree
`Refrence/freebsd-src` (spelled that way on disk) is a full FreeBSD checkout,
re-cloned 2026-08-08 after the NetBSD one was deleted for space. Never built,
never in an image; recorded in `docs/layout.md` and `docs/licensing.md` §4.
**It is 2.1 GB** — measured, and the same reason the NetBSD one was deleted.
Only `sys/dev`, `sys/net` and `sys/netinet` have ever been read, so a sparse
checkout (`--filter=blob:none --sparse`, then
`sparse-checkout set sys/dev sys/net sys/netinet`) is ~100 MB and loses nothing.
Single-file fetches from `raw.githubusercontent.com` answered every licence
question so far and cost nothing at all.
**`sys/dev/e1000/` is Intel BSD-3-clause with zero advertising clauses** —
checked. Its *device ID table* was transcribed into `include/e1000.h`, taking
the driver from binding one card (QEMU's 82540EM) to the whole 8254x family:
82540/41/44/45/46/47, ~27 IDs. That is the shape of adoption that works —
**data, not logic**. The driver stays written from Intel manual 317453, and
`NOTICE` carries the Intel attribution.
The PCIe 8257x parts (82571-82574) were deliberately *excluded*: similar
register map, different NVM access and init. Claiming a device the driver cannot
initialise is worse than not claiming it — it binds, reports a nonsense MAC, and
fails for reasons that look nothing like the cause.
## e1000
`include/e1000.h` + `kernel/drivers/net/e1000.c`, from Intel's public manual
317453. Worked on the first boot:
e1000: 52:54:0:12:34:56, link up, irq 11
**Proven to carry real traffic**, 2026-08-08: `echo n > /proc/sysrq` runs
`e1000_selftest()`, which hand-builds an ARP request for 10.0.2.2 and waits for
the reply. Under QEMU user-mode networking:
e1000: asking who has 10.0.2.2
e1000: 64 bytes from 52:55:a:0:2:2 type 806
e1000: ARP reply -- 10.0.2.2 is at 52:55:a:0:2:2
e1000: self-test passed, 1 tx 1 rx
That one exchange validates the whole path: transmit descriptor format, the DD
handshake, TDT/RDT bookkeeping, DMA addresses, the receive-ring handback, and
**the MAC filter** -- an ARP reply is unicast to us, so a wrong RAL/RAH means it
never arrives. SLIRP is silent until spoken to, which is why the test has to
transmit before it can expect to receive. Do this kind of check *before*
layering a protocol stack on a driver: otherwise the stack's first failure is
indistinguishable from its own bugs.
Run with `-netdev user,id=n0 -device e1000,netdev=n0`. Provides `e1000_transmit()`,
`e1000_receive()` and the MAC — **no IP stack yet, deliberately**: a stack
written before a driver has nothing to test against and every bug in it looks
like a driver bug.
Details that are easy to get wrong and are commented in the source:
- **Set `E1000_TXD_STAT_DD` on every transmit descriptor at init.** The hardware
only sets it after *using* one, so a driver that waits for a bit the device
has never had reason to write waits forever.
- **RDT starts at NRX-1, not NRX.** One descriptor must always be held back or
"ring full" and "ring empty" are indistinguishable.
- **Clear the multicast table.** It is undefined after reset, so left alone the
device accepts multicast for addresses nobody asked for.
- **Read the MAC from EEPROM *and* fall back to RAL/RAH.** Some cards leave the
EEPROM path unimplemented and you end up with all zeroes.
- **`irq_register(..., level_low = 1)`** — PCI interrupts are level-triggered,
active low. Edge-configured, it fires once then wedges.
- Rings and buffers come from `dma_alloc`; the device reads them by physical
address and kalloc pages are contiguous only by accident.
- Enable the receiver *after* the ring base is set, or it DMAs into physical 0,
which on this kernel is the boot page table.
`ops` is null on the pci_driver: there is no net class in the device model yet.
Adding one is the natural first step when the IP stack arrives.

View file

@ -0,0 +1,55 @@
---
name: schunix-framebuffer-device
description: /dev/fb0 is a real device with the Linux fbdev ABI; nothing may assume where the framebuffer is. Test with bootcheck.ps1 -Fb and tools/fbshot.ps1
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T16:16:17.735Z
---
**`/dev/fb0` (major 2) is a real framebuffer device** as of 2026-08-04:
`kernel/dev/fb.c` (read/write by byte offset, plus `fb_ioctl`), declared in
`include/fb.h`. Before this it was wired to `{ nullread, nullwrite }` — writing
pixels to it succeeded and did nothing.
**`include/fb.h` holds Linux's fbdev request numbers and struct layouts**
(`FBIOGET_VSCREENINFO` 0x4600, `fb_var_screeninfo` 160 bytes,
`fb_fix_screeninfo` 80 bytes). Same reasoning as
[[schunix-linux-open-flags]]: a shared number must mean the shared thing, or a
program gets a differently-shaped answer to a request it thought it
understood.
**Nothing outside `vga.c` may assume where the framebuffer is.** Ask
`vga_fb_phys()`, `vga_fb_size()`, `vga_fb_stride()`, `vga_fb_bpp()`,
`vga_fb_ptr()`. Three places used to hardcode `0xfd000000`, two of them also
hardcoding `800*600*4` — right only on QEMU stdvga at one resolution.
Two traps that cost time here:
- **`setupkvm()` runs before `vga_init()`**, so it cannot know the framebuffer
address. It used to identity-map one anyway, user-writable, into every
process ever created. That mapping is now gone entirely — nothing used it,
since userspace reaches the framebuffer through `mmap("/dev/fb0")`.
- **`sys_ioctl` answers every non-tty request with 0.** Any new device ioctl
must be routed *before* that catch-all, or the caller is told "success" and
reads an untouched buffer — a 0x0 screen that looks like a driver bug rather
than a missing one.
**To check it:**
```
pwsh -File tools/bootcheck.ps1 -Fb # 9 assertions on the reported geometry
pwsh -File tools/fbshot.ps1 -Mode fill # gradient via mmap, screendumped
pwsh -File tools/fbshot.ps1 -Mode bars # colour bars via write()
```
`cmd/fbinfo` is the program those drive. `fill` goes through mmap and `bars`
through the device's `write()` **on purpose**: when one works and the other
does not, the failure says which half broke.
Serial output cannot catch a stride bug — a framebuffer written with the wrong
row pitch reports every byte accepted and merely shears the picture. That is
why fbshot takes an actual screenshot; see
[[schunix-verify-by-driving-shell]]. Convert with `convert` in the WSL box,
not `magick`.

View file

@ -0,0 +1,49 @@
---
name: schunix-iso-boot
description: "`make iso` builds a bootable CD; it needs El Torito no-emulation plus ATAPI because the loader never uses INT 13h"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T17:28:16.714Z
---
**`make iso` produces a bootable `schunix.iso`** (added 2026-08-04). Always a
RAMROOT build — a disc that needed a second disk for its root would not be an
install medium, and read-only media cannot host a writable root anyway.
**Why it is built the way it is.** Schunix's loader never calls INT 13h: it
drives the IDE registers directly, and stage2 has already switched to
protected mode before it reads the kernel. That rules out both El Torito
emulation modes (hard-disk emulation exists only through the BIOS) and floppy
emulation (2.88 MB cap). So:
- `boot/cdboot.S` — a separate stage 1 for optical media. `tools/mkiso.sh`
passes `-boot-load-size 25`, so the BIOS loads cdboot *and* all 24 sectors of
stage2 before anything of ours runs, putting stage2 at 0x7E00 exactly where
`bootasm.S` would have. cdboot only records the medium and jumps.
- The medium flag is one byte past the verbose flag, at `VBE_INFO_ADDR + 0x101`.
**`bootasm.S` must clear it** or a warm reboot from a disc sends a hard-disk
boot looking for an optical drive.
- `stage2main.c` gained an ATAPI reader (PACKET + SCSI READ(12), 2048-byte
blocks with a one-block cache) and finds where it was loaded from by reading
the El Torito boot catalog: LBA 17, pointer at offset 0x47, then Load RBA at
catalog offset 40.
- **Do not pass `-boot-info-table`** to xorriso — it patches 56 bytes over
offset 8 of the boot image, which is the middle of cdboot's code. That is
why the boot catalog is parsed instead.
**The trap that cost the most time here:** ATAPI's 0x14/0xEB signature is only
valid until something issues a command, and the BIOS has just finished reading
the disc to load us — so on a CD boot the signature is *always* stale.
`atapi_reset()` (SRST via the control port at `io + 0x206`) is what makes the
probe work.
Two unbounded waits were hiding behind that, both of which hung with no
message on a machine with no ATA disk: `waitdisk()` in stage2main.c and
`idewait()` in kernel/dev/ide.c. Both are bounded now, `ideinit()` tolerates an
empty channel, and anything that *probes* a disk must call
`ide_disk_present()` first — `iderw()` rightly panics on an absent drive.
Check it with `pwsh -File tools/bootcheck.ps1 -Iso -Fb`, which boots with no
hard disk attached at all. See [[schunix-verify-by-driving-shell]].

View file

@ -0,0 +1,44 @@
---
name: schunix-layout-migration
description: "The bt/ -> ports/ + lib/ + cmd/ restructure done 2026-08-01, and the two build traps it exposed"
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T17:17:12.984Z
---
Restructured Schunix's tree on 2026-08-01. `bt/` became `ports/` (third-party
only, Makefile + patches, nothing else), first-party userland split into `lib/`
(runtime glue) and `cmd/<name>/main.c` (programs), tarballs to `dist/`, and all
generated output to `build/`. Documented in `docs/layout.md`. Verified: full
build passes and the image boots to `init: ready (pid 1)`.
**Two traps that bit during this and will bite again:**
1. `ld -r -b binary` derives symbol names from the input file's **path**. The
kernel embeds `initcode` and `entryother` this way, and the root Makefile
has `--redefine-sym _binary_build_lib_initcode_start=...` lines. Move either
blob's build location and the link breaks.
2. **A stamp file inside the directory it guards is always out of date.**
`ports/musl`'s `$(SRCDIR)/.patched` rule depended on `$(SRCDIR)`, but
creating the stamp bumps that directory's mtime, so the rule re-ran on every
build and failed (a patch does not apply twice). Same class of bug in
toybox and mksh. Fixed by making `$(SRCDIR)` an order-only prerequisite
(`| $(SRCDIR)`) in all three ports.
**A real thing that was silently lost-in-waiting:** musl's patch file claimed
two modified files; the build tree actually had five. The three undocumented
ones were in mallocng — two are inert debug tracing under
`#ifdef SCHUNIX_MALLOC_DEBUG`, but `glue.h` had a **required** build fix
(forward declarations for `__mmap`/`__madvise`/`__mremap`) that existed only as
a hand-edit in the build tree. Any `make distclean` would have silently lost
it. The patch was regenerated from a real diff against pristine musl 1.2.5 and
dry-run verified.
Backups from that session (delete when confident): the pre-migration tarball
`~/schunix-backup-20260801-1856.tar.gz` and `~/toybox-known-good`, both inside
WSL.
See [[schunix-build-environment]], [[schunix-rewrite-priorities]].

View file

@ -0,0 +1,68 @@
---
name: schunix-licensing
description: "Schunix is Apache-2.0, copyright Nexoniarz; BSD drivers yes, Linux no, and ports/ licences bind regardless"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-08T16:18:39.569Z
---
**`LICENSE` is Apache-2.0**, copyright *Nexoniarz* (not "Schunix Contributors" —
one author, and a fictional collective makes future re-licensing harder).
Chosen over MIT/BSD for the express patent grant.
**Settled, do not reopen:** the "Apache-2.0 but shared-source" question was
discussed at length in 2026-08 and **on 2026-08-08 Nexoniarz asked for it to be
dropped from the docs entirely** — `docs/licensing.md` is about licensing, not
about that debate. All references are gone from `docs/`, `LICENSE` and `NOTICE`;
don't reintroduce them. The substance that survives, in one line inside §1:
Apache-2.0 §2 is an irrevocable grant, so a restriction stated anywhere else
does not narrow it, and restricting distribution would be a licence change
(bespoke proprietary, or BUSL-1.1) rather than a wording change.
The decision that actually mattered to them is **when to publish**, not the
licence; the standing rule is still never ([[nexoniarz-sharing-preference]]).
**Binding regardless of what they pick:**
- The kernel derives from xv6 (MIT). `LICENSE.MIT` was *missing* — it was
referenced as `bk/LICENSE.MIT` and lost in the `bk/` → `kernel/` migration.
Recreated; must not be deleted.
- `ports/` is other people's code, redistributed in every image: musl (MIT),
toybox (0BSD), mksh (MirOS). `NOTICE` carries the attribution musl and mksh
require. Patches under `ports/*/patches/` inherit *their* licences.
New first-party files carry `// SPDX-License-Identifier: Apache-2.0`.
**Drivers: BSD yes, Linux no** (asked 2026-08-04, "for drivers we can try get
it from like Linux?"). Linux is GPL-2.0-**only**, and Apache-2.0's patent and
indemnity clauses are "further restrictions" under GPLv2 §6 — the two cannot
be combined and redistributed. Using a Linux driver means relicensing all of
Schunix to GPLv2 and dropping Apache-2.0; you cannot keep both. FreeBSD /
NetBSD / OpenBSD are BSD-2/3-clause and **are** compatible: keep the notice,
add to `NOTICE`, done. Reading Linux to understand hardware is fine;
translating a driver function-by-function produces a derivative work whatever
the identifiers say. Best source of all is the public specification — xHCI,
virtio, AHCI, NVMe, e1000 — which carries no licence on what you write from
it. Recorded in `docs/roadmap.md` §1. See [[schunix-roadmap-and-projects]].
## fs.c is third-party, and was unattributed until 2026-08-09
`kernel/fs/fs.c` (and parts of `tools/mkfs.c`) derive from **"New File System
For XV6"** by Qi Ma, Leyi Pan, Ao Sun and Peiran Xu -- **MIT License**. Nexoniarz
mentioned the provenance in passing; the tree had **no attribution at all**,
only a comment crediting xv6. That was a live compliance gap, since MIT requires
the notice be retained in substantial portions.
Their work is what the HTree directory index (`BKDRHash`, `HASHRANGE`,
`OVERFLOW_DIR_START`), the Ext4-style extents (`T_EXTENT`), the inode bitmap and
`divesymlink` all are. Now credited in `NOTICE` and in fs.c's header.
**The obligation does not go away when fs.c does.** It attaches to every copy
already made, so the NOTICE entry stays even after the SchFS migration deletes
the file.
**The general lesson:** ask where code came from before assuming it is
first-party. "It is in our tree and looks like xv6" was wrong here, and the only
reason it surfaced was an offhand remark.

View file

@ -0,0 +1,30 @@
---
name: schunix-linux-abi-decision
description: Decided 2026-08-01 — Schunix drops its native syscall ABI and keeps only the Linux one
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T17:16:39.021Z
---
Decided 2026-08-01: **Schunix will use the Linux syscall ABI as its only ABI.**
The native Schunix numbering gets deleted. Not yet implemented as of that date.
**Why:** the kernel currently carries two ABIs — 185 native entries in
`kernel/sys/table.inc` and 157 Linux numbers mapped in `kernel/sys/linux_abi.c`
— with a `proc->syscall_map` indirection on every syscall to pick between them.
100% of the shipping userland (musl, mksh, toybox) is Linux-ABI. Exactly one
program used the native ABI (`cmd/init/main.c`), and `exec.c` chose the ABI by
*guessing from the ELF entry address* (`>= 0x100000`).
**How to apply:** rebuild init against musl like every other program, then
delete `lib/usys.S`, `sys_enable_linux_abi`, the `syscall_map` indirection, the
entry-address heuristic in `exec.c`, and the duplicate native `struct stat`
path (`stati_linux` vs native). Roughly −500 lines.
The user was uneasy that this makes Schunix "not a real UNIX" — it does not.
FreeBSD (linuxulator), illumos (lx zones), and WSL1 all implement the Linux
ABI. The ABI is a calling convention, not the OS.
See [[schunix-rewrite-priorities]], [[schunix-build-environment]].

View file

@ -0,0 +1,37 @@
---
name: schunix-linux-open-flags
description: "include/fcntl.h must hold Linux's open-flag values — the userland is all Linux-ABI, and a mismatch silently corrupts files"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-03T13:59:19.392Z
---
**`include/fcntl.h` holds Linux x86-64 open-flag values, and must keep holding
them.** Every program in the userland is built against a libc that sends Linux
values; the kernel reads them with these constants. Do not "tidy" them into a
neat sequence — they are an external interface.
They disagreed until 2026-08-03, and the symptoms looked like filesystem
corruption rather than a header mistake:
- Linux `O_APPEND` (0x400) landed on Schunix's `O_TRUNC`, so **`cmd >> file`
truncated** the file it was supposed to extend.
- Linux `O_TRUNC` (0x200) landed on `O_CREATE`, so **`cmd > file` did not
truncate** and left whatever was past the new end in place.
Found by making `tools/bootcheck.ps1` do ordinary shell work — write a file,
append to it, `wc -c` the result — instead of only booting to a prompt. The
byte count was the only thing that said which failure had happened. See
[[schunix-verify-by-driving-shell]].
Two related traps fixed at the same time, both worth re-checking after any
syscall work:
- `sys_openat` silently dropped `O_TRUNC` and `O_APPEND` while `sys_open`
honoured both, so the same redirection behaved differently depending on which
entry point libc called. **Any flag handled in one must be handled in both.**
- `rmdir` was `NO_LINUX` in `kernel/sys/table.inc`, so every Linux-ABI program
got ENOSYS. A syscall with no Linux number is unreachable from userland —
see [[schunix-syscall-single-table]].

View file

@ -0,0 +1,61 @@
---
name: schunix-network-stack-choice
description: "Don't write TCP from scratch — and don't take NetBSD's either; the licence and porting evidence for lwIP vs FreeBSD."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T20:25:37.412Z
---
Decided 2026-08-08. Nexoniarz: "we should take FreeBSD or NetBSD and their
Network stuff because it's going to be hell making own TCP/IP." **Agreed on the
premise** — TCP is the one part of a network stack where writing it yourself is
a bad trade. It is not the packet format that is hard; it is congestion control,
retransmission, window management and twenty years of corner cases that only
appear against a real peer.
**But the source matters, and NetBSD is the wrong one.**
## The licence evidence (all verified, not assumed)
| | advertising clause? |
|---|---|
| NetBSD `sys/netinet/tcp_input.c`, `tcp_output.c` | **Yes** — the WIDE Project's 4-clause |
| FreeBSD `sys/netinet/tcp_input.c` | **No** — UC, Swinburne, FreeBSD Foundation, Juniper |
| lwIP | **No** — BSD-3, Swedish Institute of Computer Science |
The NetBSD clause belongs to **WIDE**, not Berkeley, so the 1999 rescission does
not reach it. Checked FreeBSD's file directly from `raw.githubusercontent.com`;
zero matches for "All advertising materials". Checked lwIP's `COPYING`; likewise
zero. This **reverses** the older assumption in `docs/roadmap.md` that NetBSD
was the intended starting point.
## The porting evidence, which matters more
FreeBSD's `sys/netinet` is not a library. `tcp_input.c` alone is 122 KB and
reaches into mbufs, `epoch(9)`, netisr, VNET, `uma(9)`, sockbufs, inpcb hash
tables and FreeBSD's locking. Taking it means porting FreeBSD's kernel
infrastructure first and finding the TCP underneath — the same conclusion
`docs/roadmap.md` §1 already reached about `sys/dev/usb`.
**lwIP was written for the situation Schunix is actually in:** a small OS with a
raw Ethernet driver and no infrastructure. Port layer is a handful of files, no
dependency on the host allocator or locking, and it brings ARP, IPv4, ICMP, UDP,
TCP, DHCP and DNS.
**Recommendation: lwIP first, FreeBSD as the reference to check against.**
## Practical notes
- Nexoniarz deleted the NetBSD checkout (`src-trunk/`) for space and said it can
be re-fetched. **Do not clone a whole BSD tree** — a sparse checkout of
`sys/net` and `sys/netinet` is all that has ever been needed, and the space
complaint was legitimate.
- One-file fetches from `raw.githubusercontent.com` are enough for a licence
check and cost nothing. That is how both findings above were made.
- Shell trap hit while doing this: `grep -c` **exits 1 when it finds zero
matches**, so `cmd && grep -c ... || echo "failed"` prints the failure message
on a perfectly successful run. Nearly reported "no network access" when the
fetch had worked.
- Whichever is chosen, it needs a `NOTICE` entry — see [[schunix-licensing]].

View file

@ -0,0 +1,45 @@
---
name: schunix-pci-drivers
description: PCI enumeration lives in kernel/dev/pci.c; drivers that find their own hardware go in kernel/drivers/ and are listed in registry.c
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T17:28:31.246Z
---
**`kernel/dev/pci.c` enumerates the PCI bus** (added 2026-08-04), through the
old 0xCF8/0xCFC config mechanism — the one that works without first finding an
ACPI table. The scan recurses through bridges rather than sweeping all 256
buses flat, so it also knows what sits behind what.
BAR sizes are found by writing all ones and reading back the bits the device
does not decode; **the original value must be restored**, since that briefly
points a live device's decoder at the top of the address space. 64-bit BARs
consume the following slot. Only header type 0 has six BARs — a bridge is
type 1 with a completely different layout.
**`kernel/drivers/` is the folder for drivers that have to *find* their
hardware.** Fixed-address legacy devices (console, keyboard, the IDE ports)
stay in `kernel/dev/`. To add one: write `kernel/drivers/<name>.c` exporting a
`struct pci_driver`, declare it in `kernel/drivers/registry.c`, add it to the
array there, and add the object to `KERN_DRIVERS` in the Makefile.
The registry is a plain array rather than the linker-section trick, on
purpose: it keeps the list of what is in the kernel somewhere you can read.
A driver silently dropped by the linker looks identical to one never written.
`kernel/drivers/vga_pci.c` is the first driver and the template. It draws
nothing — vga.c still owns the framebuffer — but it cross-checks the aperture
it finds on the bus against the address VBE reported. That disagreement is
exactly the symptom that would have surfaced first on hardware where the old
hardcoded `0xfd000000` was wrong. See [[schunix-framebuffer-device]].
**`cat /proc/pci`** lists everything found, lspci-shaped, with the binding
driver in brackets (`[-]` when unclaimed). Under QEMU's default i440fx it
should show six devices; `tools/bootcheck.ps1` asserts on the host bridge and
the IDE controller by ID, and on `[vga]` having bound.
`inl`/`outl` were added to include/x86.h for this — config space is only
reachable in dwords, so `pci_read16`/`pci_read8` shift and mask the containing
dword rather than doing a narrower bus cycle.

View file

@ -0,0 +1,71 @@
---
name: schunix-qr-encoder
description: "kernel/core/qr.c — the crash-report QR encoder, how it's verified, and the segno padding bug that is NOT ours."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T16:22:01.468Z
---
Built 2026-08-08. `kernel/core/qr.c` + `include/qr.h`, the Phase 8 crash channel
(see [[schunix-crash-screen]] and `docs/roadmap.md`).
**Wired into the crash screen the same day.** `panic.c` builds a compact payload
and paints a white card on the right of PSOGM. Verified end-to-end with
`make crashshot && make crashqr`, which decodes the symbol out of the captured
framebuffer — not out of the encoder. Payload format `SXPSOGM1`: magic, reason,
`c<cpu> p<pid> t<ticks> <name>`, `b<kernel base>`, then call-chain frames as
**offsets from the kernel base** (8 hex digits, not 16 — that is what makes a
useful number of frames fit in 134 bytes). The reason is written against a
buffer end held back by 34 bytes, so a long message costs frames but never all
of them. Deliberately not a URL: it encodes the report itself and reaches
nothing.
**Scope, deliberately narrow:** byte mode only, EC level L only, versions 1–6
only (max 134 bytes). Version 7 is where QR starts needing an 18-bit
version-information block in two extra places; version 10 is where the character
count grows to 16 bits and the alignment grid becomes 2-D. Stopping at 6 avoids
both. If a payload ever needs more, shorten the report rather than add the
tables. Freestanding: no allocation, no libc beyond memset/memmove, GF(256)
multiply by shift-and-add so there is no table init to depend on — all of which
matters because the caller is a panic handler.
**How it is verified — this is the important part.** A wrong QR still looks
exactly like a QR, so "it renders" proves nothing.
- `make qrtest` — self-checks (table arithmetic, RS generator polynomials,
finder/timing placement) and writes `build/qr.png` plus a terminal rendering
a phone can read straight off the screen. `-q` suppresses the ANSI art,
`-m TEXT` dumps the module matrix, `-f TEXT` dumps the function-pattern map.
- `make qrcheck` (`tools/qrcheck.py`) — the real oracle. Decodes with OpenCV,
*and* compares module-for-module against segno. Needs
`pip install --target <dir> opencv-python-headless numpy segno` and
PYTHONPATH; not wired into `make`.
**Two traps found doing this, both worth remembering:**
1. **segno adds a spurious zero pad codeword to every byte-mode symbol.** Its
`write_padding_bits()` does `extend([0] * (8 - (length % 8)))`, which emits a
whole byte when the stream is already codeword-aligned — and in byte mode it
always is (8L+16 bits after the terminator). ISO/IEC 18004 makes it
conditional and segno quotes that exact sentence above the line. Harmless
(decoders read `count` bytes and ignore padding) but it means the two
encoders **cannot** agree bit-for-bit on any padded payload. qrcheck compares
full matrices only for exact-fill payloads and decodes the rest. Our
behaviour is correct; do not "fix" it to match.
2. **OpenCV's QRCodeDetector fails on perfectly valid symbols**, depending on
scale and quiet-zone width. qrcheck tries six renderings, and if one still
fails it re-tests segno's symbol for the same payload — if that fails too it
reports `[detector]`, not `[FAIL]`. Without that step the check produces
false failures, which is worse than not running it.
**Debugging technique that cracked it:** dump the module matrix *and* the
function map, run the zigzag backwards in Python to recover the codewords from
both encoders, and diff those. "First differing codeword index 18" localised it
instantly; comparing matrices only ever said "108 of 441 modules differ". Note
the mask must be read from the format bits and un-applied first — un-masking
with the wrong mask makes your own output look like noise, which cost a round.
Windows note: `subprocess` cannot launch a relative path with forward slashes;
`os.path.abspath` it.

View file

@ -0,0 +1,55 @@
---
name: schunix-ramdisk-root-sizing
description: "Why `make iso` was failing with \"disk full\" — DOOM WADs in the ramdisk, not FSSIZE — and the three Makefile knobs that actually control that image."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T12:04:27.952Z
---
Fixed 2026-08-08. `make iso` died with `mkfs: disk full -- used 6000 of 6000
blocks` and the advice "Increase FSSIZE in param.h". **`FSSIZE` was 150000 and
entirely innocent.** The ramdisk root's size is `RAMDISK_BLOCKS` in the Makefile.
The error message has been rewritten to name the right knob per image and print
the metadata breakdown; don't trust the old advice if it resurfaces anywhere.
**The actual cause: `make doom` stages 33 MB of WADs into `sysroot/`**
(`freedoom1.wad` 28.8 MB, `doom1.wad` 4.2 MB) and the ramdisk was trying to
swallow them into 6 MB. Real userland is only ~1.9 MB — the 244 `/bin` entries
are *hardlinks* to one 822 KB toybox, which is why `du` says 35 MB while the
file sizes sum to 229 MB.
**This is a licensing rule, not just a size one, and that is the reason it is
enforced in the build rather than by remembering to run `make doom-uninstall`.**
The ramdisk is linked *into the kernel binary*; `doom1.wad` is
non-redistributable shareware, so a kernel containing it could not be
distributed. See [[schunix-doom-acceptance-test]] and [[schunix-licensing]].
Three Makefile knobs, none of them FSSIZE:
- `RAMDISK_BLOCKS` (6000) — image size, BSIZE is 1024.
- `RAMDISK_NINODES` (2000) — **the inode table is preallocated.** mkfs's default
12000 inodes cost 1501 of 6000 blocks, a quarter of the image, for ~260 files.
Metadata went 1596 → 345 blocks.
- `RAMDISK_EXCLUDE` (`usr/share/doom bin/doom`) — `mkfs --exclude <relpath>`,
repeatable, drops a whole subtree and prints what it skipped.
`mkfs` gained `--ninodes` and `--exclude`. Its option parser used to handle
exactly one flag then `break`, silently ignoring a second — now it compacts argv
properly, but both modes still identify themselves *positionally* (argv[2] is
`--sysroot` or the first file), so keep that contract if adding options.
**Verified:** ramroot boots with no disk attached at all, and the ISO boots from
optical media only (`ide: no drive on the primary channel`) to a full shell with
243 commands — 243 not 244 because `doom` is correctly excluded.
**`make iso` needs `mkisofs`, not xorriso, on this host.** There is no xorriso
package in scoop; cdrtools provides `mkisofs.exe` and was already installed.
`tools/mkiso.sh` now takes either — the El Torito flags are argument-compatible.
This also finally verified `-boot-load-size 49` (see
[[schunix-boot-splash-logo]]), which was untestable while xorriso was missing.
**Trap:** `make iso` builds the ramroot variant and does `rm -rf build/kernel`
first, so a failed run leaves objects compiled with `-DRAMDISK_ROOT` and the next
plain `make` dies on `_binary_ramdisk_size`. Recover with
`rm -rf build/kernel build/lib build/cmd`.

View file

@ -0,0 +1,45 @@
---
name: schunix-rewrite-priorities
description: "The ordered list of what actually blocks Schunix, established by a full read of the kernel on 2026-08-01"
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T19:01:25.126Z
---
Assessed the whole Schunix kernel (~12k hand-written lines) on 2026-08-01.
Code quality is good; the problem is that xv6 lacks the *concepts* a real UNIX
needs, so the fix is missing struct fields and subsystems, not bug fixing.
Order to work in (each item unblocks the next):
1. ~~**`struct proc` fields.**~~ **DONE 2026-08-01.** `xstate`, `killsig`,
`pgid`, `sid`, and uid/euid/suid + gid/egid/sgid added; `exit()` takes a
wait(2)-encoded status; waitpid() reports it. Verified: `sh -c "exit 42"`
yields `rc=42`, `id` prints `uid=0(root)`. Also required coupling
TIOCGPGRP to the real pgid — see [[schunix-tty-fg-pgrp-coupling]].
2. **Signal delivery on the trap return path.** Currently only fires on
syscall return (`kernel/sys/syscall.c`), so a process spinning in userland
never receives a signal.
3. **Fault-recoverable `copy_from_user`/`copy_to_user` with an exception
table.** `uva2ka()` returns a P2V alias valid for exactly one page; two
separate historical crashes trace to that single design flaw.
4. **A page-fault handler.** `trap.c` has no `T_PGFLT` case at all — hence no
demand paging, no COW, no lazy stack growth.
5. **SCHFS directory format.** Every directory is ≥113KB on disk, capped at
119KB; `DIRSIZ` is 14; `dirent.inum` is a `ushort` (65535-inode ceiling).
Disk-format break, so decide before accumulating images worth keeping.
6. **Then** the Ring 0A/0B split. Its own Phase 1 is "just file moves" — doing
it before 1-5 means moving the same files twice.
Separately, a real confirmed bug: `kernel/arch/x86_64/trapasm.S` saves
`rax/rcx/rdx` to three **global** scratch words (`_sc_rax` etc. in `vm.c`) with
a comment claiming "IF is off, no race" — but the Makefile runs `-smp 2`, and
`cli` only masks the local CPU. Two CPUs in `syscall_entry` clobber each
other's saved user `rip`. Use per-CPU storage.
Also: `KSTACKSIZE` is 4096 (one page) on x86-64; Linux uses 16K. The stack
canary exists because it really did overflow.
See [[schunix-linux-abi-decision]], [[schunix-layout-migration]].

View file

@ -0,0 +1,80 @@
---
name: schunix-roadmap-and-projects
description: "The agreed roadmap through USB, GPU, fonts, network and the GUI; Schcom/SchWM/SchDE and the installer live in projects/, outside the OS tree"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T23:05:57.185Z
---
**Set out 2026-08-04.** Recorded in `docs/roadmap.md`; the naming and the
split are Nexoniarz's.
Order asked for: USB → GPU → OTF/TTF → network + sockets → GUI → installer.
Kept, with one dependency fix: shared driver infrastructure came first
([[schunix-driver-infrastructure]]), because it was the first step of USB
rather than a detour from it.
**The GUI stack, all three named by Nexoniarz and all in `projects/`:**
- **Schcom** — Schunix Compositor. Owns the display and input; composites
client buffers. Policy is *not* its job.
- **SchWM** — window manager, a privileged Schcom client.
- **SchDE** — desktop environment: panel, launcher, settings.
- **schinst** — installer, installation environment, and `.sip` tooling.
`projects/` is at the repo root and is **not part of the kernel build** —
things that run *on* Schunix, not things that *are* Schunix. They link against
the sysroot libc and never against kernel headers. The reason is that each is
larger than the kernel will be for a long time, and grown in-tree the
boundary would blur until "does Schunix boot" stopped having a clear answer.
**The two hard blockers before Schcom can start**, both kernel work:
1. **Unix domain sockets.** `socket()` is still a `NO_LINUX` stub. Needed for
the client protocol and for `SCM_RIGHTS` — a pipe cannot carry a buffer
handle. Build these *before* TCP/IP; they are simpler and they are what
the socket layer's shape should be designed around.
2. **Raw input events** — an evdev-style `/dev/input`. Input only arrives
cooked through the tty line discipline, line-buffered to one foreground
process, which is not usable by a compositor.
shm and framebuffer mmap are already done ([[schunix-shm-and-mmap]]).
**`.sip`** = Schunix Installation Package, modelled on WIM: file-based rather
than block-based, so one file holds a whole tree, applies to any partition
size, and dedups across images. Most of it exists — `tools/mkschfs.c` and
`tools/mkfs.c` already do content dedup with an `(st_dev, st_ino)` fast path.
`mksip` should share that code.
**Confirmed order (2026-08-05):** finish USB, then sockets + raw input, then
virtio-gpu.
**Doom moved (2026-08-05).** It was "explicitly last"; it is now the
**acceptance test** — "an OS that doesn't run Doom is not a real OS".
fbDOOM at that. It is a real load test: framebuffer mmap, raw termios,
file I/O, allocator and timing, all at once. Everything on that list already
exists except possibly `gettimeofday`/`nanosleep` resolution, so **it could
be attempted today** — it needs no sockets, USB, virtio-gpu or fonts.
**It is a test, not a component** — clarified by Nexoniarz: built and run to
prove things work, never shipped in the system. That disposes of licensing
entirely, since nothing is redistributed. Belongs with the test tooling (a
`make doomtest`, same shape as `make crashshot`), not in `cmd/` or the
sysroot. *If* it were ever shipped the licensing returns: engine is GPL-2.0
(fine in `ports/`, never `cmd/`), and the WAD would have to be **Freedoom**
(BSD-3-clause) because `doom1.wad` is not redistributable.
**Sized honestly and not to be underestimated:** OTF/TTF is a rasteriser plus
a variable-width glyph cache, and the console assumes a fixed 8x16 cell grid
throughout — proportional text is a new rendering layer, not a font swap.
**Fonts must be public domain or very permissive** — "PD0", clarified
2026-08-05 (the earlier note read it as "CY-NO", which was never a real
thing). CC0 is ideal; MIT/BSD/Apache fine. SIL OFL is usable but carries a
rename-if-modified obligation, so read it first. A font ships inside every
image, so its licence attaches to the image — see [[schunix-licensing]].
First GPU driver should be **virtio-gpu**, not Intel/AMD: public spec, QEMU
implements it, and it teaches the resource/flush model that a raw framebuffer
never will. Doom stays last. See [[nexoniarz-sharing-preference]].

View file

@ -0,0 +1,46 @@
---
name: schunix-schapi-direction
description: "Schunix is aimed at being a NeXTSTEP/macOS-shaped system with a native SchAPI over a POSIX personality, with permanent Windows-grade compatibility"
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-07T16:29:50.730Z
---
**Agreed 2026-08-06.** Schunix is no longer "a Unix-like kernel with a userland
attached". It is aimed at being a **system**: NeXTSTEP/macOS-shaped layering,
with POSIX/Unix kept complete but demoted from identity to *personality*, and a
native higher-level API called **SchAPI** above it. Written up in
`docs/architecture/schapi.md`.
The architecture is borrowed from macOS; the compatibility policy is borrowed
from **Windows** (nothing is ever removed after 1.0). Those are two different
systems — macOS drops things aggressively — and the mix is deliberate.
**The load-bearing mechanism:** the stable ABI is a *library*
(`libschunix`), not the syscall table. That is why macOS can renumber syscalls
freely while old binaries keep working, and why Linux is stuck with every
syscall mistake it ever made. Syscalls become private and renumberable; only
libschunix may issue them. This does **not** contradict
[[schunix-linux-abi-decision]] — Linux numbering stays as the Linux
personality's entry path, frozen because Linux froze it, not because we did.
Everything today is tier `provisional`; the freeze starts at 1.0 and covers
only what is explicitly marked `stable` then. Committing now would enshrine
things like the `fcntl(F_SETFL)` no-op fixed in [[schunix-doom-acceptance-test]].
`compat/` holds **shims written in terms of the current API**, not parallel
implementations. If an old entry point cannot be expressed as a translation,
that is a defect in the new API to fix *before* freezing.
`src-trunk/` is a NetBSD checkout — reference material and a parts bin, never
built, never in an image. Highest-value pieces: `sys/net`+`sys/netinet` (the
TCP/IP stack, for the roadmap's Phase 2), `subr_pool.c` (slab allocator), and
the autoconf driver model. **NetBSD is not uniformly licensed** — a minority of
files still carry the 4-clause advertising clause and Berkeley's 1999
rescission only covers UC-copyrighted files. Check every file before copying:
`grep -l "All advertising materials"`. See [[schunix-licensing]].
Ordering is unchanged: this is a direction, not a task. The native Windows
build has to boot first — see [[schunix-build-environment]].

View file

@ -0,0 +1,48 @@
---
name: schunix-shm-and-mmap
description: Shared memory and file-backed mmap work now; the rule is that pages a process does not own must never reach kfree
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T18:45:47.215Z
---
**Shared memory and file-backed mmap were implemented on 2026-08-04**, in
`kernel/sys/sys_mem.c`. These were the two syscall gaps blocking a display
server: without shm, every frame is a copy through a pipe.
- `shmget`/`shmat`/`shmdt` — up to 16 segments of 2 MB. Pages are allocated up
front and zeroed (a fresh segment handing out the last tenant's bytes would
leak them between processes). A segment lives until its last attachment
goes. **Their table.inc entries were `NO_LINUX`**, so a musl-linked program
never reached them at all; they now carry Linux's numbers (29/30/67).
- `mmap` of a regular file reads the contents in. It used to silently return
zeroed anonymous pages -- success-shaped, and wrong. **Writable `MAP_SHARED`
on a file returns `-ENODEV`** rather than faking it: its whole contract is
that stores reach the file, and there is no page cache that could make that
true.
**The rule that matters: pages a process does not own must never reach
kfree().** Three places had to learn it, and each was a real hazard:
- `vm_unmap_range()` kfree's everything it unmaps. Use
`vm_unmap_norelease()` for device memory and shared segments.
- `freevm()` frees any mapped page below `PHYSTOP`. The framebuffer survived
only by accident (0xfd000000 is above PHYSTOP); shm pages are kalloc'd and
sit below it. `proc_release_shared()` runs before freevm in both `exit()`
and `exec()` -- and in exec it must run **before** the `memset(proc->vma)`,
which erases the record of which mappings were shared.
- `fork()` must map the *same* physical pages and bump the attachment count.
`copy_vma_range()`'s share branch now copies the parent's PTEs rather than
special-casing the framebuffer address.
`struct vma`'s `is_fb` became `no_free` + `shmid` for this reason.
**Test with `shmtest`** (`cmd/shmtest`, nine checks) — it forks and has the
child write where the parent reads, which is the only convincing proof. Run
via `pwsh -File tools/bootcheck.ps1 -Tty`.
Still missing for a GUI: raw keyboard/mouse events that bypass the tty line
discipline, and unix domain sockets (`socket()` is still a NO_LINUX stub).
See [[schunix-console-tty]] and [[schunix-framebuffer-device]].

View file

@ -0,0 +1,98 @@
---
name: schunix-smp-and-memory-bugs
description: "SMP works as of 2026-08-08 — the five bugs it took, the __thread lesson, and the 224 MB RAM floor that is also gone."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T19:21:20.756Z
---
Both of Nexoniarz's 2026-08-08 reports are **fixed**. `docs/roadmap.md` §3b has
the full write-up.
## SMP works
One, two and four processors boot; every CPU prints `cpu%d: starting` and
`/proc/cpuinfo` lists them all. `smp_start_aps` in `kernel/core/main.c` is 1.
Verified on QEMU at `-smp 1/2/4`, with all four `bootcheck` modes passing.
**Five bugs, each hidden behind the one before it.** All were invisible under
QEMU, which publishes only one processor in the legacy MP table; VMware
published the real count, which is why the triple fault only ever appeared
there.
1. **Enumeration** — `mpinit()` read the 1994 Intel MP table.
`kernel/arch/x86_64/acpi.c` now reads the ACPI MADT (RSDP → RSDT/XSDT →
MADT type 0). Do this *first*: nothing else is reproducible until QEMU
reports more than one CPU.
2. **Triple fault** — `entry64high` in `entry.S` had **no AP branch at all**;
the check was a comment saying it had never been written. Every AP fell into
the BSP's path, took its stack, and ran `kmain()` twice. Now tests `EBX == 1`,
which `entryother.S` had always been setting.
3. **`panic("acquire")`** — `seginit()` called `kalloc()` for its per-CPU page.
kalloc locks, `acquire()` calls `holding()`, `holding()` reads `cpu` — which
`seginit()` was about to set up. `startothers()` now preallocates each AP's
page on the BSP.
4. **`panic("sched locks")`** — `kernel_fs_base` was **one global** written by
every CPU and read by `trapasm.S` on every entry from user mode, so all CPUs
shared one per-CPU block. `RESTORE_KERNEL_FS` now reads `%gs:0`; `seginit()`
sets GS base per CPU. No swapgs, because `sys_arch_prctl()` implements only
ARCH_SET_FS/GET_FS and musl uses FS for TLS — **if ARCH_SET_GS is ever added
this must become a swapgs pair.**
5. **Null `proc` in `sleep()`, then `#GP` in `syscall_entry`** — see below.
## The lesson: `__thread` is wrong for per-CPU kernel data
`cpu` and `proc` were `__thread`. That means per *thread*, and the compiler is
entitled to cache the thread pointer across calls on that basis:
movq %fs:0x0, %rax # read the thread pointer
movq %rax, -0x18(%rbp) # spill it
callq sched # <-- the CPU can change here
movq -0x18(%rbp), %rax # reload the OLD pointer
movq (%rax,%rcx), %rax # proc, from the previous CPU's block
movq $0x0, 0x40(%rax) # NULL, because that CPU set proc = 0
But `%fs` here is per *CPU*, and a kernel thread changes CPU at every `sched()`.
Now `mycpu()`/`myproc()` — inline `volatile` asm reading `%gs` at each use, so
nothing can be hoisted across a call. Named that way (as xv6-riscv does) and
**not** `cpu`/`proc`, because a macro by those names would also rewrite every
`struct cpu` and `struct proc`; macros have no namespace, tags do.
**Per-CPU block layout — shared with assembly, do not reorder:**
+0 self-pointer x86-64 TLS ABI (%fs:0)
+8 syscall scratch trapasm.S parks %rsp here
+16 struct cpu * PERCPU_CPU
+24 struct proc * PERCPU_PROC
**Offset 8 is not free.** `syscall_entry` uses `%fs:(8)` as scratch, and since
FS base and GS base are the same address, `%fs:8` *is* `%gs:8`. Putting `cpu`
there made every syscall overwrite it with `%rsp` — a `#GP` inside
`syscall_entry`. That was bug 5.
## The 224 MB RAM floor is gone
`kernel/arch/x86_64/e820.c` reads the firmware map and sets `phystop`.
**Floor is 32 MB, was 224 MB**; 512 MB in gives `MemTotal: 524160 kB`. Take the
end of the usable region *containing the kernel* (`EXTMEM`), not the global
maximum — `freerange()` walks from the kernel's end to `phystop`, so jumping a
reserved hole puts firmware tables on the free list. Ceiling `PHYSTOP_MAX` is
3 GB: `kvmalloc()` maps 0–4 GB as four 1 GB pages with the top one uncached for
PCI BARs.
## Techniques worth reusing
- `qemu -d int,cpu_reset -D log` printed `v=0e` with `SP=ffff80000000ffb0` — a
stack pointer in low memory, which said instantly the AP was not on its own
stack and pointed at the entry path rather than anything in C.
- The symbol table is too sparse for nearest-preceding-symbol. Use
`build/kernel/kernel.asm` and
`awk '/^[0-9a-f]+ <.*>:/{fn=$0} index($0,"<addr>:")==1{print fn; print; exit}'`
— gives the enclosing function *and* the exact instruction.
- **Do not blanket-`sed` an identifier rename.** Doing that for `proc`/`cpu`
rewrote `#include "proc.h"`, string literals and declarations, and this is not
a git repo so there was no undo. The recovery that worked: revert the whole
substitution, reapply only the safe `proc->` → `myproc()->` form, then let the
compiler name each remaining site.

View file

@ -0,0 +1,52 @@
---
name: schunix-sockets-and-evdev
description: "Unix domain sockets and /dev/input evdev both work; the one lock rule, the names-not-in-the-VFS tradeoff, and where the syscall numbers went"
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-05T07:12:20.950Z
---
Unix domain sockets (`kernel/net/unix.c`, `include/socket.h`, `kernel/sys/sys_net.c`)
and raw input events (`kernel/dev/input.c`, `include/input.h`) are both done and
verified — `tools\bootcheck.ps1 -Sock` and `-Evdev`.
**One lock for the whole socket table, deliberately.** A pipe gets a lock per
pipe because every operation touches one object; a socket operation touches
two, and per-socket locks need an ordering rule every path obeys or they
deadlock. Worse, a reader testing `peer_closed` under its own lock while the
closer sets it under the peer's has no interlock and sleeps through its own
wakeup. Don't "optimise" this into per-socket locks.
**Socket names live in a table, not the filesystem.** `ls -l /tmp/schcom` will
not show an `s` and `unlink()` of a socket path returns ENOENT. Fixing it
properly means a new inode type through both filesystems, iget, dirlookup and
stat — a VFS change, not a socket change. Everything that matters works.
`connect()` deliberately does **not** wait for `accept()`. A queued connection
is already established on Unix; blocking would deadlock any server that
connects to itself.
The syscalls had real Linux numbers assigned (41/49/50/43/42 and friends).
They were `NO_LINUX` stubs before, which meant a musl program calling
`socket()` got "bad syscall" from the ABI translation and never reached the
stub at all — same trap as [[schunix-shm-and-mmap]]. `shutdown` is registered
as `sockshutdown` because Schunix already has a native `shutdown` that powers
the machine off.
**evdev:** `/dev/input/event0` (keyboard) and `event1` (mouse), major 7, Linux
`struct input_event` layout. Fed by the PS/2 keyboard, the PS/2 mouse and USB
HID. The console keeps its own path — both consumers see every key, so keys
pressed during a test also land on the shell's input line.
Two things that are not obvious:
- Below 0x54 a Linux keycode **is** the AT set-1 scancode, so the PS/2 path
needs no table. USB HID does need one (`hid_keycode[]`), because HID numbers
keys by position and the AT set numbers them by matrix wiring order.
- A full event ring drops the **oldest** event. Dropping the newest is the
obvious choice and it is wrong: the event most likely lost is a key release,
and a lost release leaves the key held down forever.
See also [[schunix-console-tty]], [[schunix-framebuffer-device]],
[[schunix-verify-by-driving-shell]].

View file

@ -0,0 +1,36 @@
---
name: schunix-syscall-single-table
description: Syscalls are declared once in kernel/sys/table.inc — never hand-edit linux_abi.c
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T19:34:55.691Z
---
Since 2026-08-01 a Schunix syscall is declared in exactly **one** place:
```
SYSCALL(nr, linux_nr, name) // kernel/sys/table.inc
SYSCALL_ALIAS(linux_nr, name) // extra Linux number -> existing handler
```
and implemented as `SYSCALL_DEFINE(name) { ... }` in a `kernel/sys/sys_*.c`.
Generated from that one line: the `SYS_*` enum (`include/syscall.h`), the
dispatch table (`kernel/sys/syscall.c`), and the Linux ABI translation map
(`kernel/sys/linux_abi.c`).
**Why:** it used to take three edits in two files — a `table.inc` line, a
`#define LINUX_foo N`, and a map entry. Forgetting the second file left the
syscall unreachable from every musl-linked program while looking correctly
registered. `linux_abi.c` went from 361 hand-written lines to 34 generated ones.
**How to apply:** never add anything to `linux_abi.c` — there is nothing to
edit there. `NO_LINUX` marks a syscall with no Linux equivalent; all such
entries collide on scratch index 511, which is never dispatched.
184 syscalls, 150 with Linux numbers, 7 aliases (vfork→fork, preadv→readv, …).
See [[schunix-linux-abi-decision]] — dropping the native ABI entirely is still
the plan; this refactor is the prerequisite, not a replacement for it.

View file

@ -0,0 +1,84 @@
---
name: schunix-text-rendering
description: "The text layer that replaces the 8x16 character-cell grid — glyph metrics, surfaces, coverage blending, UTF-8."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T22:45:34.934Z
---
Built 2026-08-09. `include/text.h` + `kernel/dev/text.c`. Nexoniarz: "using ANSI
fonts in 2026 is a not funny joke."
**The font was never the problem — the grid was.** The console indexes
`font_data[256][16]` and steps the cursor eight pixels. A grid cannot do
variable-width glyphs, cannot position text off a multiple of eight, cannot
anti-alias, and cannot represent a codepoint above 255. A nicer bitmap fixes
none of that.
Three decisions that make the layer worth having:
- **Glyphs carry metrics** (`w`, `h`, `bearing_x/y`, `advance`). Variable-width
text *is* that; everything else follows. `text_measure()` exists because you
cannot centre or lay out anything without it.
- **Draws into a `struct surface`, not the screen.** `{pixels, stride, width,
height}`. A compositor draws off-screen and presents; rendering that writes
straight to the VGA aperture is useless to it. Works on the virtio-gpu back
buffer, a window buffer, or the VGA aperture, none of which know about each
other.
- **`struct font_ops` behind an interface, and glyphs carry 8-bit *coverage*,
not bits.** The bitmap font fills it with 0 and 255 only, which costs nothing
— and a TrueType rasteriser then plugs in with no caller changing, because it
already has somewhere to put anti-aliased edges. `blend_px()` reads the
destination for the same reason.
Pen position is on the **baseline**, as every non-terminal text API does, so
glyphs of different heights sit on a common line.
**Verified** with `echo t > /proc/sysrq` + `tools/textshot.ps1`: text at x=61
and x=63, a checkerboard background visible *around* the glyphs (no cell boxes),
`┌─┐ █▓▒░ ° ± → •` above U+00FF, and a string measured at 160 px then centred on
it. The old console renders in the same screenshot with solid cell backgrounds —
a useful side-by-side.
## Traps hit
- `include/font.h` **defines** `font_data` rather than declaring it, so
including it from a second file duplicates the symbol under `-fno-common`.
text.c declares `extern unsigned char font_data[256][16];` instead; the header
is generated data and is better left alone.
- The built-in font is **CP437, not Latin-1** (known trap in this codebase).
`to_cp437()` maps the ~25 codepoints worth having — box drawing, blocks,
arrows, degree. **That table exists to be deleted** when a real font lands; it
is a bridge, not a design.
- A missing glyph draws U+25A1 or `?`, never nothing. Silently shorter text
hides the fault.
- `utf8_next()` always advances, even on malformed input — a decoder that does
not is a hang, not a rendering artefact. Rejects surrogates and overlong
encodings.
## The typefaces, decided 2026-08-09
- **Roboto** for the DE / WM / compositor. **Apache-2.0** -- the same licence as
Schunix, so it needs only a NOTICE entry.
- **JetBrains Mono** for the terminal / TTY. **SIL OFL 1.1**, and it declares
**no Reserved Font Name** (checked: the copyright line names none; the only
"reserved font name" hit in OFL.txt is the licence defining the term). So a
modified version may keep the name -- which matters, because pre-rasterising
a font into bitmaps at build time *is* a modification and an RFN would have
forbidden keeping the name on the result.
OFL keeps the font under OFL inside an Apache-2.0 system, does not reach
anything else, and only forbids selling the font on its own. Both fetched and
verified at source, not assumed. Full note in `docs/licensing.md` ("Fonts").
Neither is in the tree yet -- there is no rasteriser to use them, and shipping
a TTF nothing can read would be dead weight.
## Not done
The console still uses the old path; nothing is ported to this yet. Porting
`vga_putc` onto it is the obvious follow-up and would delete the cell grid for
good. A TTF rasteriser is a separate provider behind `font_ops` — the layer is
what was blocking it, and it no longer is.

View file

@ -0,0 +1,86 @@
---
name: schunix-ttf-rasteriser
description: The TrueType rasteriser — why glyf and not CFF, and the composite-glyph trap that loses the letter "i".
metadata:
type: project
---
Built 2026-08-09. `include/ttf.h` + `kernel/dev/ttf.c`, the second `font_ops`
provider behind [[schunix-text-rendering]]. Host harness: `tools/ttftest.c` →
`build/tools/ttftest font.ttf px "text" out.pgm`.
Renders real JetBrains Mono at 32 px: correct advances, anti-aliased edges,
the slashed zero, dots on i and j.
## glyf, not CFF — and that was checked, not assumed
`.ttf` and `.otf` are both sfnt containers with the same table directory, cmap
and hmtx. Only the *outlines* differ: TrueType puts quadratic B-splines in
`glyf`; OpenType/PostScript puts cubic béziers in `CFF ` as Type 2 charstrings,
a stack-based interpreted format and a separate project.
**Both chosen fonts ship `glyf` and neither ships `CFF `** — verified by dumping
JetBrains Mono's table directory before writing a line. So CFF buys nothing
today. `ttf_open()` rejects `OTTO` explicitly rather than failing later with an
empty glyph and no explanation.
## The trap: composite glyphs are not an edge case
First working render produced **"Schun x"** — the `i` was missing. `i` is a
*composite* glyph: dotless-i plus the dot, assembled from two other glyphs at
offsets. Skipping composites (`numberOfContours < 0`) does not just lose
accented characters, it loses a letter from the project's own name.
Now assembled: parse the component list (flags, glyph index, dx/dy, optional
scale), recurse, merge contours with the offset applied. Only the
**translation** case is handled — components with a scale or 2×2 matrix are
skipped, because applying a transform wrongly is worse than not applying one.
## Other things that bite
- Every sfnt field is **big-endian**; read byte by byte, never cast. The file is
not guaranteed aligned and a cast byte-swaps everything on x86.
- Glyph flags are **run-length encoded** (bit 3 = "next byte is a repeat
count"). Missing that turns any glyph with a repeated flag into garbage.
- Coordinates are **deltas** in one of three encodings per axis, selected by two
flag bits — including "same as previous", which occupies no bytes.
- TrueType **omits the on-curve point between two consecutive off-curve
points**; it must be synthesised at the midpoint or curves render as corners.
- `hmtx` stores `numberOfHMetrics` full entries then bearings only — clamp the
index.
- cmap format 4's `idRangeOffset` is a byte offset **from its own address**,
which is why that arithmetic looks the way it does.
## Glyph cache (added the same day)
Direct-mapped over printable ASCII 32..126 into a 48 KB pool, filled linearly,
never freed. No hashing, no eviction policy, no allocation — the contents are a
pure function of (codepoint, size), so nothing ever goes stale. A **size change
drops the whole cache**, which is the only event that invalidates it.
It fixes two things, not one: the obvious cost of re-rasterising every glyph,
and the contract — the single `ttf_cov` scratch buffer meant a glyph was valid
only until the next call, so a caller could not hold two at once. Cached glyphs
now point into the pool. Codepoints outside ASCII, and anything that arrives
after the pool is full, still render correctly through the scratch buffer under
the old contract.
`tools/ttftest.c` calls through `fo->ops->glyph`, not `ttf_render`, so the cache
is on the tested path rather than bypassed by the harness.
## Still not done
- **No font loaded from disk.** The kernel compiles the rasteriser but no `.ttf`
is in the tree and nothing reads one. This is what blocks pointing the console
at it.
- **Hinting ignored** (`fpgm`/`prep`/`cvt ` skipped). Fine above ~16 px, blurry
below. Real hinting is a bytecode interpreter — a project, not a fix.
- **No GSUB**, so ligatures do not form: JetBrains Mono's `==` renders as two
glyphs. Correct, just not what the font can do.
- **CFF/OTF outlines.** Requested 2026-08-09 for people who want a non-TrueType
font in the terminal. Not started, and it is not "more of the same": Type 2
charstrings are a stack-based bytecode to *interpret* — INDEX/DICT structures,
charset, local and global subrs with the biasing rule, ~40 operators, cubic
béziers. Roughly the size of everything else here, and a subtly wrong
interpreter draws plausible-looking wrong glyphs. It plugs in as a third
`font_ops` provider, so nothing above it changes.

View file

@ -0,0 +1,34 @@
---
name: schunix-tty-fg-pgrp-coupling
description: TIOCGPGRP and getpgrp() must agree or the shell spins forever at startup
metadata:
node_type: memory
type: project
originSessionId: d1a1ea76-f919-411c-b0ef-0e1bc5064563
modified: 2026-08-01T19:01:09.569Z
---
`tcgetpgrp()` (TIOCGPGRP in `kernel/sys/sys_fs.c`) and `getpgrp()` must return
the same value, or mksh hangs at startup in its standard loop:
```c
while (tcgetpgrp(tty) != getpgrp()) kill(0, SIGTTIN);
```
**Why this is a trap:** both used to answer `proc->pid` independently, so they
agreed *by accident*. The moment `getpgrp()` started reporting a real process
group (which a process inherits from its parent, so pid != pgid), they
disagreed permanently and the shell spun — RUNNING, never printing a prompt,
never exiting, with no error anywhere.
Fixed 2026-08-01 by adding `tty_fg_pgrp` (defined in `kernel/core/console.c`,
declared in `defs.h`), initialised to 1 so the first shell — which inherits
init's group — sees itself as foreground. TIOCGPGRP reads it, TIOCSPGRP writes
it. Anything that changes process-group semantics must keep these coupled.
**Diagnosing this class of hang:** Ctrl-P over the serial console dumps the
process table (`procdump`, wired up in `console.c`). A shell shown as `run`
rather than `sleep` means a busy loop in userland, not a crash — and if
`exit()` is never reached, nothing is dying, so look for a spin, not a fault.
See [[schunix-verify-by-driving-shell]], [[schunix-rewrite-priorities]].

View file

@ -0,0 +1,45 @@
---
name: schunix-usb-ids
description: "usb.ids ships as /usr/share/usb.ids, cached in dist/ and refreshed with `make usb-ids` — deliberately not on every build."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T22:10:41.961Z
---
Added 2026-08-09 at Nexoniarz's request. `dist/usb.ids` →
`sysroot/usr/share/usb.ids`, 730 KB, 25,673 lines, version 2026.06.26.
**Licence: dual GPL-2.0-or-later OR 3-clause BSD.** The file's own header says
nothing; the statement is on <http://www.linux-usb.org/usb-ids.html>: "under the
terms of either the GNU General Public License (version 2 or later) or of the
3-clause BSD License." Schunix takes the **BSD** option — the GPL one could not
be exercised, since Apache-2.0 and GPL-2.0-only cannot be combined. `NOTICE`
carries the attribution and says which option was taken.
## Two places I deliberately did not do what was asked
**"Update every compilation."** It is cached in `dist/` and refreshed only by
`make usb-ids`. Fetching per build would break offline builds, make the same
source produce different images on different days, and put a third-party web
server on the critical path of `make` — and `dist/` exists precisely so a
rebuild never needs the network (stated in `docs/layout.md`). The file is
committed and carries its own version and date, so what shipped is always
knowable. The update target validates the download (`grep -q "^# Version:"`)
before replacing the cached copy, so a truncated file or an error page cannot
silently destroy a good database.
**It is not in the kernel.** 730 KB against a ~770 KB kernel. Linux does not do
this either — the kernel reports vendor:product and `lsusb` resolves names from
`/usr/share/hwdata/usb.ids` in userspace. Same split here: `/proc/devices` and
the USB core report IDs; a future `lsusb` reads the database.
Also added to `RAMDISK_EXCLUDE` — 730 KB is 12% of the 6 MB ramdisk root and
nothing needs it to boot. Verified: `mkfs: excluding usr/share/usb.ids`, ramroot
metadata unchanged at 345 blocks. This is the second real use of that mechanism
(see [[schunix-ramdisk-root-sizing]]).
**Still to do:** nothing reads it yet. An `lsusb` in `cmd/` that walks the USB
core's device list and looks names up is the natural next step, and would make
`/proc/devices` far more legible than `1234:1111`.

View file

@ -0,0 +1,118 @@
---
name: schunix-usb-xhci
description: "xHCI enumerates USB2 and USB3 devices and does control transfers; written from the spec because porting BSD's stack means porting newbus first"
metadata:
node_type: memory
type: project
originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225
modified: 2026-08-04T23:06:09.296Z
---
**`kernel/drivers/xhci.c`, 2026-08-04.** Working: controller reset, DCBAA,
command ring, event ring + ERST, scratchpad buffers, port reset, Enable Slot,
Address Device, and control transfers. Three QEMU devices (usb-kbd, usb-mouse,
usb-storage) enumerate and answer `GET_DESCRIPTOR`.
**Written from the xHCI 1.2 spec, not ported from BSD** — and that was the
right call, not laziness. FreeBSD's `sys/dev/usb` is ~100k lines welded to
newbus, devclass, `bus_dma`, callouts and taskqueues; porting it means porting
FreeBSD's device framework first. BSD is worth far more as the reference you
check yourself against. (Licensing agrees: BSD is Apache-compatible, Linux is
not — [[schunix-licensing]].)
**Things that would cost hours to rediscover:**
- **Enumeration polls the event ring on purpose.** `pci_bind_all()` runs from
`kmain()` with interrupts off and no scheduler; a probe that waited for an
interrupt would wait forever.
- **Context size is 32 *or* 64 bytes**, per `HCCPARAMS1.CSZ`. Hardcoding 32 on
a 64-byte controller puts every field at half the right offset and commands
are rejected with no explanation.
- **PORTSC's change bits and PED are write-1-to-clear.** Writing back a value
you just read clears whatever happened. Mask `PORT_RW1C` off every write.
- **USB3 ports arrive already enabled; USB2 ports need `PORT_PR` first.**
Check PED rather than trying to work out which is which.
- **Full-speed devices' EP0 max packet size is unknowable until you ask.**
Start at 8, read 8 bytes of the device descriptor, then Evaluate Context.
- The link TRB's Toggle Cycle bit is what makes a fixed ring endless; set the
cycle bit *last* when pushing a TRB.
- A No-Op command before enumerating proves the ring and event path, so a
failure points at our plumbing rather than at a device.
**The USB core and HID landed 2026-08-05.** `kernel/drivers/usb.c` reads the
configuration, selects it, and offers the device to a class-driver table in
`registry.c` (same one-table-you-can-read arrangement as the PCI drivers);
`kernel/drivers/usb_hid.c` does boot-protocol keyboard and mouse. A command
typed on an emulated USB keyboard runs in the shell.
- **Boot protocol, not report descriptors.** A device claiming the boot
subclass promises a fixed 8-byte keyboard / 3-byte mouse layout, so no
report-descriptor parser is needed. It still starts in *report* protocol --
`SET_PROTOCOL(0)` is required or the reports arrive in an unknown shape.
- `SET_IDLE 0` too, or a keyboard resends its state every few ms forever.
- The keyboard report is a *state*, not an event: a held key appears in every
report. Emit only usages absent from the previous report.
- Characters go into a small queue that `consoleintr()` then drains through
its getc argument -- that reuses the whole line discipline instead of
reimplementing it. `mouse_inject()` in mouse.c is the same idea for the
pointer; HID's Y axis points down and PS/2's points up.
- A configuration descriptor is a *chain* of descriptors of varying types.
Walk it by each one's length byte; class-specific ones you have never heard
of sit in the middle and must be skipped, not tripped over.
**The interrupt bug that cost the most time is in
[[schunix-driver-infrastructure]]** -- transfers completed perfectly, events
sat in the ring, and the IRQ count stayed at zero.
**Mass storage landed 2026-08-05** (`kernel/drivers/usb_storage.c`): bulk
endpoints, bulk-only transport, SCSI INQUIRY / READ CAPACITY / READ(10) /
WRITE(10), and a block device at `USBDISK_DEV` (3) routed from `bdevrw()` in
bio.c. A real superblock reads back through `bread()`.
- **A bulk transfer at probe time must not sleep.** This wedged the machine
mid-enumeration with no message at all. `pci_bind_all()` runs from
`kmain()` with interrupts off and *before* `pinit()` — so `sleep()` has no
scheduler to switch to and `wakeup()` would take an uninitialised ptable
lock. `struct xhci` has a `can_sleep` flag, set once probing is over; until
then bulk transfers poll the event ring, exactly as enumeration does.
- Bulk-only transport is strictly three phases: 31-byte CBW out, data,
13-byte CSW in. **The CSW is not optional** — skipping it leaves the
endpoint out of phase and every later command fails.
- SCSI is big-endian everywhere, and READ CAPACITY returns the address of the
*last* sector, not a count.
- No GET MAX LUN: single-LUN devices answer it with a STALL, and recovering
from an EP0 stall needs a Reset Endpoint command that is not written yet.
- `b->data` lives in the buffer cache, not DMA memory, so everything bounces
through one `dma_alloc`'d block.
**Bugs found in a deliberate audit pass (2026-08-05), all fixed:**
- **Context Entries must be the highest DCI in use, not the one being added.**
Writing the new endpoint's own DCI works only while endpoints are opened in
increasing order; open a higher DCI then a lower one and the field shrinks,
silently invalidating the endpoint that already worked. `xhci_open_ep`
tracks `slot[].max_dci` now, and `usb_storage.c` deliberately opens bulk
OUT before bulk IN — the formerly-broken order — so the fix stays tested.
- **The event ring had two unlocked consumers**: `xhci_irq` and the sysrq
dump. Both take `x->lock` now; `transfer_done` runs with it held.
- **A failed block read left B_VALID clear and returned the stale buffer**,
which `bread()` hands straight to its caller as though it were disk
content. Three retries, then panic.
- **`bot_command` skipped the CSW when the data phase failed**, leaving the
device holding a status nobody asked for — so the *next* command's data
phase would receive this one's status, turning one failed read into every
later read failing. The CSW is now always read.
- **The shared descriptor scratch buffer was not cleared between devices**, so
a short reply left the previous device's descriptor visible past the end of
its own and the parser walked into it.
- Max ESIT Payload was being set for bulk endpoints; it is periodic-only.
- `irq_unregister` compacted a chain a dispatcher could be walking, and the
comment above the lock claimed nothing was ever removed. It masks the line
for the rewrite now.
**Two debug commands:** `echo u > /proc/sysrq` dumps controller state and
drains the event ring by hand — that is what separates "no event" from
"event, no interrupt". `echo s > /proc/sysrq` reads block 1 off the USB disk
through the buffer cache.
**Still not done:** hubs. See `docs/roadmap.md` phase 1.

View file

@ -0,0 +1,34 @@
---
name: schunix-verify-by-driving-shell
description: "Boots to a prompt" is not a passing test for Schunix — drive real commands over the serial console
metadata:
type: feedback
---
Reaching `init: ready (pid 1)` and a `#` prompt proves almost nothing about
Schunix. A filesystem change shipped looking "verified" that way and the user
found the breakage by typing one command.
**Why:** nothing in the boot path opens a file from `/bin` or reads a
non-root inode. An image with corrupt inodes boots to a perfectly healthy
prompt and dies on the first `ls`.
**How to apply:** after any change to `tools/mkfs.c`, the on-disk format, or
`kernel/fs/*`, drive actual commands and read the output. QEMU's serial line is
an input console, but it has no flow control — a burst of piped input loses
about half its characters, and input sent before the shell is up is discarded
entirely. Both problems go away with: wait ~20s for boot, then send one
character every ~0.12s.
```sh
send() { s="$1"; i=0
while [ $i -lt ${#s} ]; do
printf '%s' "$(expr substr "$s" $((i+1)) 1)"; sleep 0.12; i=$((i+1)); done
printf '\n'; sleep 7; }
sleep 20; send "ls"; send "ls /bin"; send "cat /etc/motd"
```
Pipe that into `qemu ... -serial stdio -display none` and read the log. `ls`,
`ls /bin` and `cat` on a real file are the minimum bar.
See [[schfs-v2-and-ramdisk]], [[schunix-build-environment]].

View file

@ -0,0 +1,59 @@
---
name: schunix-virtio-gpu
description: "virtio-gpu 2D driver — the display class's second implementation; how to run it and the transport details that matter."
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T19:59:27.522Z
---
Written 2026-08-08 from the OASIS virtio 1.1 specification (chapters 2, 4.1,
5.7). `include/virtio.h` + `kernel/drivers/virtio_gpu.c`. Worked on the first
boot.
virtio-gpu: 1280x800, 4000 KB framebuffer, queue 64
display: 1280x800 32bpp stride 5120
display: presented a full frame
**Run it with:** `-vga std -device virtio-gpu-pci`. Keep `-vga std` — the
bootloader draws the splash through VBE and there is no virtio path that early,
so a machine with *only* virtio-gpu boots blind until the kernel is up. Both
register as `DEVCLASS_DISPLAY`; `registry.c` lists virtio-gpu **first** so
`device_find_class()` returns it, and vga_pci still claims the stdvga card
underneath for the splash and text console.
**Why it matters over VBE:** presenting is explicit. VBE is a flat aperture with
no notion of a finished frame, so a compositor races the scanout. Here you
`TRANSFER_TO_HOST_2D` a rectangle then `RESOURCE_FLUSH` it, and the host never
shows a half-drawn frame. `acquire()` returns the guest-side framebuffer
directly — no separate back buffer is needed, because the host cannot see it
until the transfer. `wait_vblank` is deliberately null: whole rectangles are
atomic, so there is nothing to synchronise against, and the wrappers in
device.c handle a null op.
**Transport details that are easy to get wrong:**
- Structures live at an offset inside *whichever BAR the device names* in a
vendor-specific PCI capability (`PCI_CAP_ID_VNDR`, 0x09). Assuming BAR 0 works
on QEMU and is exactly the assumption that breaks on the next device.
- The status handshake order is not negotiable: reset → ACK → DRIVER → read
features → write features → FEATURES_OK → **read FEATURES_OK back** (the
device clears it to reject, and that readback is the only way it ever tells
you) → queues → DRIVER_OK.
- `VIRTIO_F_VERSION_1` is bit 32, so it lives in the *second* 32-bit window —
hence the feature-select registers.
- Notify address is `notify_base + queue_notify_off * notify_off_multiplier`;
the multiplier is a trailing field of the notify capability only.
- MMIO accessed at exact widths. The common config has 8/16/32-bit fields
adjacent, and a device may ignore a wrong-width access silently.
- 64-bit queue address registers are written as two 32-bit halves, low first.
Polled, not interrupt-driven: every caller wants the answer before continuing,
and the wait is microseconds. The poll loop is bounded so a wedged device
cannot hang boot.
`/proc/devices` name and driver columns are 14 wide because `virtio-gpu0` is
eleven characters and overran ten.
**Not done:** cursor queue, multiple scanouts, mode setting (the resolution is
whatever the host reports), resource reuse on resolution change, and 3D/virgl.

View file

@ -0,0 +1,51 @@
---
name: schunix-wall-clock
description: The clock read 1970 because nothing ever called cmostime(); kernel/core/time.c now reads the RTC at boot.
metadata:
node_type: memory
type: project
originSessionId: 9d8a2aef-bcf0-47a0-96c6-f706efab4a95
modified: 2026-08-08T19:44:10.022Z
---
Fixed 2026-08-08. Nexoniarz reported "time has stopped, or it's just too slow —
type `date`, wait 10 seconds, it's still the same or pretty close."
**The clock was not stopped.** `ticks` advanced correctly (measured: uptime
19.81 → 34.51 over ~14 real seconds, identical at `-smp 1` and `-smp 2`). The
bug was the *origin*: `sys_gettimeofday`, `sys_clock_gettime` and `sys_time` all
returned `ticks / 100` — seconds since **boot**, presented as seconds since the
**epoch**. So every machine believed it was 1 Jan 1970 and the date crawled at
the rate of its own uptime. `cmostime()` in `kernel/arch/x86_64/lapic.c` could
read the hardware clock the whole time and had no caller.
`kernel/core/time.c` now reads the RTC once in `main()` (after the timer is
running, before userspace), converts it with Howard Hinnant's
`days_from_civil`, and stores `boot_epoch`. `wallclock(&sec, &usec)` is the one
place that answers "what time is it", so the three syscalls cannot disagree.
- **`days_from_civil`, not a loop over years** — branchless, exact for any year,
no table. Accumulating 365/366 since 1970 gets leap centuries wrong unless you
remember 1900 was not a leap year and 2000 was.
- **RTC read once, not per call** — it is polled over two I/O ports and must be
sampled twice to catch it mid-update.
- **Interpreted as UTC.** A PC that has had Windows on it usually keeps the RTC
in local time, but there is no timezone store and nowhere to ask. `date` shows
UTC; Nexoniarz is UTC+2 in summer, so it reads two hours behind local. When a
settings store exists, the offset goes in `time_init()`.
- **`CLOCK_MONOTONIC` (clkid 1) subtracts `boot_epoch`**; everything else is
treated as realtime.
- Implausible CMOS readings leave `boot_epoch` at 0, i.e. the old behaviour,
rather than putting the machine in the Bronze Age.
Verified: `Sat Aug 8 19:36:43 UTC 2026`, advancing 14s in 14 real seconds, host
at 21:37 CEST.
## The stale-image trap, now guarded
A running QEMU holds `build/fs.img` open; on Windows `rm` then fails with
"Device or resource busy", and **make stops after the kernel has already
relinked**. The next test boots the *previous* kernel and the change looks like
it did nothing. This cost several cycles across the session. The `fs.img` rule
now prints what is wrong and how to clear it. When a fix appears to have no
effect, check `schunix.img`'s timestamp before debugging the code.