From 3182da39651686722b473f5758a2e9f1afc94f83 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Sun, 9 Aug 2026 14:27:22 +0200 Subject: [PATCH] Carry project memory in the repo, with tools/memory-sync.sh Claude Code keeps per-project memory at /.claude/projects//, where 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. --- .claude/memory/MEMORY.md | 44 ++++ .../memory/nexoniarz-sharing-preference.md | 21 ++ .claude/memory/schfs-btree-phase1.md | 42 ++++ .claude/memory/schfs-phase2-mount.md | 45 ++++ .claude/memory/schfs-phase3-writes.md | 194 ++++++++++++++++++ .claude/memory/schfs-v2-and-ramdisk.md | 39 ++++ .claude/memory/schos-cross-platform-build.md | 58 ++++++ .claude/memory/schos-memory-sync.md | 46 +++++ .claude/memory/schos-rename-and-git.md | 76 +++++++ .claude/memory/schunix-boot-splash-logo.md | 61 ++++++ .claude/memory/schunix-build-environment.md | 54 +++++ .claude/memory/schunix-build-speed.md | 50 +++++ .claude/memory/schunix-console-tty.md | 67 ++++++ .claude/memory/schunix-crash-screen.md | 57 +++++ .claude/memory/schunix-devfs-procfs.md | 49 +++++ .claude/memory/schunix-device-model.md | 92 +++++++++ .claude/memory/schunix-dirsiz-14.md | 48 +++++ .../memory/schunix-doom-acceptance-test.md | 46 +++++ .../memory/schunix-driver-infrastructure.md | 60 ++++++ .../memory/schunix-e1000-and-driver-layout.md | 114 ++++++++++ .claude/memory/schunix-framebuffer-device.md | 55 +++++ .claude/memory/schunix-iso-boot.md | 49 +++++ .claude/memory/schunix-layout-migration.md | 44 ++++ .claude/memory/schunix-licensing.md | 68 ++++++ .claude/memory/schunix-linux-abi-decision.md | 30 +++ .claude/memory/schunix-linux-open-flags.md | 37 ++++ .../memory/schunix-network-stack-choice.md | 61 ++++++ .claude/memory/schunix-pci-drivers.md | 45 ++++ .claude/memory/schunix-qr-encoder.md | 71 +++++++ .claude/memory/schunix-ramdisk-root-sizing.md | 55 +++++ .claude/memory/schunix-rewrite-priorities.md | 45 ++++ .../memory/schunix-roadmap-and-projects.md | 80 ++++++++ .claude/memory/schunix-schapi-direction.md | 46 +++++ .claude/memory/schunix-shm-and-mmap.md | 48 +++++ .claude/memory/schunix-smp-and-memory-bugs.md | 98 +++++++++ .claude/memory/schunix-sockets-and-evdev.md | 52 +++++ .../memory/schunix-syscall-single-table.md | 36 ++++ .claude/memory/schunix-text-rendering.md | 84 ++++++++ .claude/memory/schunix-ttf-rasteriser.md | 86 ++++++++ .../memory/schunix-tty-fg-pgrp-coupling.md | 34 +++ .claude/memory/schunix-usb-ids.md | 45 ++++ .claude/memory/schunix-usb-xhci.md | 118 +++++++++++ .../memory/schunix-verify-by-driving-shell.md | 34 +++ .claude/memory/schunix-virtio-gpu.md | 59 ++++++ .claude/memory/schunix-wall-clock.md | 51 +++++ .gitignore | 5 + tools/memory-sync.sh | 111 ++++++++++ 47 files changed, 2810 insertions(+) create mode 100644 .claude/memory/MEMORY.md create mode 100644 .claude/memory/nexoniarz-sharing-preference.md create mode 100644 .claude/memory/schfs-btree-phase1.md create mode 100644 .claude/memory/schfs-phase2-mount.md create mode 100644 .claude/memory/schfs-phase3-writes.md create mode 100644 .claude/memory/schfs-v2-and-ramdisk.md create mode 100644 .claude/memory/schos-cross-platform-build.md create mode 100644 .claude/memory/schos-memory-sync.md create mode 100644 .claude/memory/schos-rename-and-git.md create mode 100644 .claude/memory/schunix-boot-splash-logo.md create mode 100644 .claude/memory/schunix-build-environment.md create mode 100644 .claude/memory/schunix-build-speed.md create mode 100644 .claude/memory/schunix-console-tty.md create mode 100644 .claude/memory/schunix-crash-screen.md create mode 100644 .claude/memory/schunix-devfs-procfs.md create mode 100644 .claude/memory/schunix-device-model.md create mode 100644 .claude/memory/schunix-dirsiz-14.md create mode 100644 .claude/memory/schunix-doom-acceptance-test.md create mode 100644 .claude/memory/schunix-driver-infrastructure.md create mode 100644 .claude/memory/schunix-e1000-and-driver-layout.md create mode 100644 .claude/memory/schunix-framebuffer-device.md create mode 100644 .claude/memory/schunix-iso-boot.md create mode 100644 .claude/memory/schunix-layout-migration.md create mode 100644 .claude/memory/schunix-licensing.md create mode 100644 .claude/memory/schunix-linux-abi-decision.md create mode 100644 .claude/memory/schunix-linux-open-flags.md create mode 100644 .claude/memory/schunix-network-stack-choice.md create mode 100644 .claude/memory/schunix-pci-drivers.md create mode 100644 .claude/memory/schunix-qr-encoder.md create mode 100644 .claude/memory/schunix-ramdisk-root-sizing.md create mode 100644 .claude/memory/schunix-rewrite-priorities.md create mode 100644 .claude/memory/schunix-roadmap-and-projects.md create mode 100644 .claude/memory/schunix-schapi-direction.md create mode 100644 .claude/memory/schunix-shm-and-mmap.md create mode 100644 .claude/memory/schunix-smp-and-memory-bugs.md create mode 100644 .claude/memory/schunix-sockets-and-evdev.md create mode 100644 .claude/memory/schunix-syscall-single-table.md create mode 100644 .claude/memory/schunix-text-rendering.md create mode 100644 .claude/memory/schunix-ttf-rasteriser.md create mode 100644 .claude/memory/schunix-tty-fg-pgrp-coupling.md create mode 100644 .claude/memory/schunix-usb-ids.md create mode 100644 .claude/memory/schunix-usb-xhci.md create mode 100644 .claude/memory/schunix-verify-by-driving-shell.md create mode 100644 .claude/memory/schunix-virtio-gpu.md create mode 100644 .claude/memory/schunix-wall-clock.md create mode 100644 tools/memory-sync.sh diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..1ae02b1 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -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. diff --git a/.claude/memory/nexoniarz-sharing-preference.md b/.claude/memory/nexoniarz-sharing-preference.md new file mode 100644 index 0000000..4e2472a --- /dev/null +++ b/.claude/memory/nexoniarz-sharing-preference.md @@ -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. diff --git a/.claude/memory/schfs-btree-phase1.md b/.claude/memory/schfs-btree-phase1.md new file mode 100644 index 0000000..3f3505c --- /dev/null +++ b/.claude/memory/schfs-btree-phase1.md @@ -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]]. diff --git a/.claude/memory/schfs-phase2-mount.md b/.claude/memory/schfs-phase2-mount.md new file mode 100644 index 0000000..4e59035 --- /dev/null +++ b/.claude/memory/schfs-phase2-mount.md @@ -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]]). diff --git a/.claude/memory/schfs-phase3-writes.md b/.claude/memory/schfs-phase3-writes.md new file mode 100644 index 0000000..cfdb83a --- /dev/null +++ b/.claude/memory/schfs-phase3-writes.md @@ -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//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. diff --git a/.claude/memory/schfs-v2-and-ramdisk.md b/.claude/memory/schfs-v2-and-ramdisk.md new file mode 100644 index 0000000..475bf73 --- /dev/null +++ b/.claude/memory/schfs-v2-and-ramdisk.md @@ -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 +` 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]]. diff --git a/.claude/memory/schos-cross-platform-build.md b/.claude/memory/schos-cross-platform-build.md new file mode 100644 index 0000000..e33fddb --- /dev/null +++ b/.claude/memory/schos-cross-platform-build.md @@ -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 +`` and ``. + +**The four things that were actually host-specific**, all fixed — worth +recognising the pattern before adding a fifth: + +1. `` is glibc/musl only. macOS and the BSDs have no such + header and take `major()`/`minor()` from ``. 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. diff --git a/.claude/memory/schos-memory-sync.md b/.claude/memory/schos-memory-sync.md new file mode 100644 index 0000000..c600afb --- /dev/null +++ b/.claude/memory/schos-memory-sync.md @@ -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 +`/.claude/projects//memory/`, where `` 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. diff --git a/.claude/memory/schos-rename-and-git.md b/.claude/memory/schos-rename-and-git.md new file mode 100644 index 0000000..e434166 --- /dev/null +++ b/.claude/memory/schos-rename-and-git.md @@ -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. diff --git a/.claude/memory/schunix-boot-splash-logo.md b/.claude/memory/schunix-boot-splash-logo.md new file mode 100644 index 0000000..33ab778 --- /dev/null +++ b/.claude/memory/schunix-boot-splash-logo.md @@ -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. diff --git a/.claude/memory/schunix-build-environment.md b/.claude/memory/schunix-build-environment.md new file mode 100644 index 0000000..7203591 --- /dev/null +++ b/.claude/memory/schunix-build-environment.md @@ -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 ``/``, 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]]. diff --git a/.claude/memory/schunix-build-speed.md b/.claude/memory/schunix-build-speed.md new file mode 100644 index 0000000..96fb79c --- /dev/null +++ b/.claude/memory/schunix-build-speed.md @@ -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. diff --git a/.claude/memory/schunix-console-tty.md b/.claude/memory/schunix-console-tty.md new file mode 100644 index 0000000..e501181 --- /dev/null +++ b/.claude/memory/schunix-console-tty.md @@ -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]]. diff --git a/.claude/memory/schunix-crash-screen.md b/.claude/memory/schunix-crash-screen.md new file mode 100644 index 0000000..686925d --- /dev/null +++ b/.claude/memory/schunix-crash-screen.md @@ -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]]. diff --git a/.claude/memory/schunix-devfs-procfs.md b/.claude/memory/schunix-devfs-procfs.md new file mode 100644 index 0000000..bfb5e02 --- /dev/null +++ b/.claude/memory/schunix-devfs-procfs.md @@ -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//` 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//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. diff --git a/.claude/memory/schunix-device-model.md b/.claude/memory/schunix-device-model.md new file mode 100644 index 0000000..e58fe63 --- /dev/null +++ b/.claude/memory/schunix-device-model.md @@ -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. diff --git a/.claude/memory/schunix-dirsiz-14.md b/.claude/memory/schunix-dirsiz-14.md new file mode 100644 index 0000000..ed9d49e --- /dev/null +++ b/.claude/memory/schunix-dirsiz-14.md @@ -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. diff --git a/.claude/memory/schunix-doom-acceptance-test.md b/.claude/memory/schunix-doom-acceptance-test.md new file mode 100644 index 0000000..f03fd05 --- /dev/null +++ b/.claude/memory/schunix-doom-acceptance-test.md @@ -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. diff --git a/.claude/memory/schunix-driver-infrastructure.md b/.claude/memory/schunix-driver-infrastructure.md new file mode 100644 index 0000000..0392561 --- /dev/null +++ b/.claude/memory/schunix-driver-infrastructure.md @@ -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]]. diff --git a/.claude/memory/schunix-e1000-and-driver-layout.md b/.claude/memory/schunix-e1000-and-driver-layout.md new file mode 100644 index 0000000..ebfa508 --- /dev/null +++ b/.claude/memory/schunix-e1000-and-driver-layout.md @@ -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/// + + 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. diff --git a/.claude/memory/schunix-framebuffer-device.md b/.claude/memory/schunix-framebuffer-device.md new file mode 100644 index 0000000..c535865 --- /dev/null +++ b/.claude/memory/schunix-framebuffer-device.md @@ -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`. diff --git a/.claude/memory/schunix-iso-boot.md b/.claude/memory/schunix-iso-boot.md new file mode 100644 index 0000000..3cc9014 --- /dev/null +++ b/.claude/memory/schunix-iso-boot.md @@ -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]]. diff --git a/.claude/memory/schunix-layout-migration.md b/.claude/memory/schunix-layout-migration.md new file mode 100644 index 0000000..4828678 --- /dev/null +++ b/.claude/memory/schunix-layout-migration.md @@ -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//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]]. diff --git a/.claude/memory/schunix-licensing.md b/.claude/memory/schunix-licensing.md new file mode 100644 index 0000000..38b8b69 --- /dev/null +++ b/.claude/memory/schunix-licensing.md @@ -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. diff --git a/.claude/memory/schunix-linux-abi-decision.md b/.claude/memory/schunix-linux-abi-decision.md new file mode 100644 index 0000000..c589a74 --- /dev/null +++ b/.claude/memory/schunix-linux-abi-decision.md @@ -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]]. diff --git a/.claude/memory/schunix-linux-open-flags.md b/.claude/memory/schunix-linux-open-flags.md new file mode 100644 index 0000000..0136bd6 --- /dev/null +++ b/.claude/memory/schunix-linux-open-flags.md @@ -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]]. diff --git a/.claude/memory/schunix-network-stack-choice.md b/.claude/memory/schunix-network-stack-choice.md new file mode 100644 index 0000000..98469ea --- /dev/null +++ b/.claude/memory/schunix-network-stack-choice.md @@ -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]]. diff --git a/.claude/memory/schunix-pci-drivers.md b/.claude/memory/schunix-pci-drivers.md new file mode 100644 index 0000000..8f2bccb --- /dev/null +++ b/.claude/memory/schunix-pci-drivers.md @@ -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/.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. diff --git a/.claude/memory/schunix-qr-encoder.md b/.claude/memory/schunix-qr-encoder.md new file mode 100644 index 0000000..49d78e7 --- /dev/null +++ b/.claude/memory/schunix-qr-encoder.md @@ -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 p t `, `b`, 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 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. diff --git a/.claude/memory/schunix-ramdisk-root-sizing.md b/.claude/memory/schunix-ramdisk-root-sizing.md new file mode 100644 index 0000000..f7c3de2 --- /dev/null +++ b/.claude/memory/schunix-ramdisk-root-sizing.md @@ -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 `, + 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`. diff --git a/.claude/memory/schunix-rewrite-priorities.md b/.claude/memory/schunix-rewrite-priorities.md new file mode 100644 index 0000000..d89b209 --- /dev/null +++ b/.claude/memory/schunix-rewrite-priorities.md @@ -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]]. diff --git a/.claude/memory/schunix-roadmap-and-projects.md b/.claude/memory/schunix-roadmap-and-projects.md new file mode 100644 index 0000000..c9c0b1e --- /dev/null +++ b/.claude/memory/schunix-roadmap-and-projects.md @@ -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]]. diff --git a/.claude/memory/schunix-schapi-direction.md b/.claude/memory/schunix-schapi-direction.md new file mode 100644 index 0000000..175317a --- /dev/null +++ b/.claude/memory/schunix-schapi-direction.md @@ -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]]. diff --git a/.claude/memory/schunix-shm-and-mmap.md b/.claude/memory/schunix-shm-and-mmap.md new file mode 100644 index 0000000..12dc55b --- /dev/null +++ b/.claude/memory/schunix-shm-and-mmap.md @@ -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]]. diff --git a/.claude/memory/schunix-smp-and-memory-bugs.md b/.claude/memory/schunix-smp-and-memory-bugs.md new file mode 100644 index 0000000..1cae046 --- /dev/null +++ b/.claude/memory/schunix-smp-and-memory-bugs.md @@ -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,":")==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. diff --git a/.claude/memory/schunix-sockets-and-evdev.md b/.claude/memory/schunix-sockets-and-evdev.md new file mode 100644 index 0000000..fb9541c --- /dev/null +++ b/.claude/memory/schunix-sockets-and-evdev.md @@ -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]]. diff --git a/.claude/memory/schunix-syscall-single-table.md b/.claude/memory/schunix-syscall-single-table.md new file mode 100644 index 0000000..90b6646 --- /dev/null +++ b/.claude/memory/schunix-syscall-single-table.md @@ -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. diff --git a/.claude/memory/schunix-text-rendering.md b/.claude/memory/schunix-text-rendering.md new file mode 100644 index 0000000..673e922 --- /dev/null +++ b/.claude/memory/schunix-text-rendering.md @@ -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. diff --git a/.claude/memory/schunix-ttf-rasteriser.md b/.claude/memory/schunix-ttf-rasteriser.md new file mode 100644 index 0000000..7960cb0 --- /dev/null +++ b/.claude/memory/schunix-ttf-rasteriser.md @@ -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. diff --git a/.claude/memory/schunix-tty-fg-pgrp-coupling.md b/.claude/memory/schunix-tty-fg-pgrp-coupling.md new file mode 100644 index 0000000..c6a050e --- /dev/null +++ b/.claude/memory/schunix-tty-fg-pgrp-coupling.md @@ -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]]. diff --git a/.claude/memory/schunix-usb-ids.md b/.claude/memory/schunix-usb-ids.md new file mode 100644 index 0000000..23aa366 --- /dev/null +++ b/.claude/memory/schunix-usb-ids.md @@ -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 : "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`. diff --git a/.claude/memory/schunix-usb-xhci.md b/.claude/memory/schunix-usb-xhci.md new file mode 100644 index 0000000..e77820f --- /dev/null +++ b/.claude/memory/schunix-usb-xhci.md @@ -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. diff --git a/.claude/memory/schunix-verify-by-driving-shell.md b/.claude/memory/schunix-verify-by-driving-shell.md new file mode 100644 index 0000000..9814c3b --- /dev/null +++ b/.claude/memory/schunix-verify-by-driving-shell.md @@ -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]]. diff --git a/.claude/memory/schunix-virtio-gpu.md b/.claude/memory/schunix-virtio-gpu.md new file mode 100644 index 0000000..2fbf62a --- /dev/null +++ b/.claude/memory/schunix-virtio-gpu.md @@ -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. diff --git a/.claude/memory/schunix-wall-clock.md b/.claude/memory/schunix-wall-clock.md new file mode 100644 index 0000000..ae2def7 --- /dev/null +++ b/.claude/memory/schunix-wall-clock.md @@ -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. diff --git a/.gitignore b/.gitignore index 7824aa5..bcd3248 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist/ *.log *.ppm *.png.tmp + +# Claude Code's per-machine settings (permissions granted on this host). +# .claude/memory/ IS tracked -- it is the project memory, and committing it is +# how it survives a directory rename and reaches the other machines. +.claude/settings.local.json diff --git a/tools/memory-sync.sh b/tools/memory-sync.sh new file mode 100644 index 0000000..d7eb1ec --- /dev/null +++ b/tools/memory-sync.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# Move Claude Code's project memory between this repo and the local machine. +# +# tools/memory-sync.sh export memory on this machine -> .claude/memory/ +# tools/memory-sync.sh import .claude/memory/ -> this machine +# tools/memory-sync.sh status show both sides, change nothing +# +# Why this exists +# --------------- +# Claude Code keeps per-project memory outside the repo, under +# +# /.claude/projects//memory/ +# +# where is the project's absolute path with the separators replaced by +# dashes: D:\DEV\SchOS becomes D--DEV-SchOS, /home/me/dev/SchOS becomes +# -home-me-dev-SchOS. Two consequences follow, and both have already bitten +# this project once: +# +# * Renaming the directory orphans the memory. D:\DEV\Schunix -> D:\DEV\SchOS +# changed the key, and everything written under the old one became +# invisible until it was moved across by hand. +# * The key is machine-specific, so memory written on Windows is not where +# Linux looks for it, even for the same checkout. +# +# Committing the memory to the repo fixes both: git carries it between hosts +# and across renames, and `import` puts it wherever this particular machine +# expects to find it. Run `export` after a session that learned something and +# `import` after cloning or pulling on another machine. + +set -eu + +usage() { + echo "usage: $0 {export|import|status}" >&2 + exit 2 +} + +[ $# -eq 1 ] || usage + +# Repo root: this script lives in tools/, so the parent of its directory. +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo=$(CDPATH= cd -- "$script_dir/.." && pwd) +repo_mem="$repo/.claude/memory" + +# The absolute path Claude Code itself sees. Under Git Bash `pwd` gives +# /d/DEV/SchOS, but Claude Code is a Windows program and uses D:\DEV\SchOS, so +# the key must be derived from the Windows form or it will not match. +native_path="$repo" +case "$(uname -s)" in +MINGW* | MSYS* | CYGWIN*) + if command -v cygpath >/dev/null 2>&1; then + native_path=$(cygpath -w "$repo") + fi + ;; +esac + +# Encode: every separator becomes a dash, and so does the drive colon. +# +# D:\DEV\SchOS -> D--DEV-SchOS +# /home/me/dev/SchOS -> -home-me-dev-SchOS +# +# The colon maps to a dash rather than being deleted -- that is where the +# doubled dash in the Windows form comes from, and dropping it instead yields +# D-DEV-SchOS, which matches nothing. +key=$(printf '%s' "$native_path" | tr '/\\:' '---') + +home_dir=${HOME:-$USERPROFILE} +host_mem="$home_dir/.claude/projects/$key/memory" + +count() { [ -d "$1" ] && find "$1" -name '*.md' 2>/dev/null | wc -l | tr -d ' ' || echo 0; } + +case "$1" in +status) + echo "repo: $repo" + echo "as Claude sees it: $native_path" + echo "project key: $key" + echo + echo "in repo: $repo_mem" + echo " $(count "$repo_mem") memories" + echo "on this host: $host_mem" + echo " $(count "$host_mem") memories" + ;; + +export) + if [ ! -d "$host_mem" ]; then + echo "memory-sync: nothing to export -- no memory at $host_mem" >&2 + echo " (is the project key right? run: $0 status)" >&2 + exit 1 + fi + mkdir -p "$repo_mem" + # Copy rather than mirror: deleting from the repo is a git operation and + # should be visible in a diff, not a side effect of running a sync script. + cp "$host_mem"/*.md "$repo_mem"/ 2>/dev/null || true + echo "exported $(count "$repo_mem") memories to .claude/memory/" + echo "commit them to carry them to your other machines." + ;; + +import) + if [ ! -d "$repo_mem" ]; then + echo "memory-sync: nothing to import -- no .claude/memory/ in this repo" >&2 + exit 1 + fi + mkdir -p "$host_mem" + cp "$repo_mem"/*.md "$host_mem"/ 2>/dev/null || true + echo "imported $(count "$host_mem") memories to:" + echo " $host_mem" + ;; + +*) + usage + ;; +esac