<feed xmlns='http://www.w3.org/2005/Atom'>
<title>gbos.git, branch master</title>
<subtitle>gbos - a tiny Unix-flavored OS for the Game Boy Color (kernel, shell, coreutils in C, RAM filesystem)</subtitle>
<id>https://git.sl0p.foo/gbos.git/atom/?h=master</id>
<link rel='self' href='https://git.sl0p.foo/gbos.git/atom/?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/'/>
<updated>2026-09-24T21:08:54Z</updated>
<entry>
<title>usr: ntpdate + date - the Game Boy knows what day it is</title>
<updated>2026-09-24T21:08:54Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T21:08:54Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=be1a8f74e48d4ed2ae3a0b78a7137b4bc41a2969'/>
<id>urn:sha1:be1a8f74e48d4ed2ae3a0b78a7137b4bc41a2969</id>
<content type='text'>
An MBC5 cart has no RTC, so SNTP is the only route to a wall clock. ntpdate
does one 48-byte UDP exchange on port 123 and hands the stamp to the kernel;
SYS_SETTIME keeps it with the tick it arrived at, and SYS_GETTIME adds the
elapsed ticks back, so date(1) is just formatting.

    /# ntpdate
    ntpdate: clock set from pool.ntp.org
    /# date
    Sep 24 21:01:23 UTC 2026        (host said 21:01:26)

The clock does not survive a power cycle - which is exactly what /rc is for:
`ntpdate` there and the machine boots knowing the time.

All of it is bounded subtraction, no long division or shifts: SDCC pulls those
from sm83.lib, whose modules link into the $A000 RAM window. The month tables
are string literals for the same family of reason - an initialized array goes
to _INITIALIZED at $A000 and the image overflows its bank ("buffer too small"
out of makebin), which is how this first failed to link.
</content>
</entry>
<entry>
<title>usr: sort, cmp, strings, du, time</title>
<updated>2026-09-24T21:08:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T21:08:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=4bfe20f88b93645f9a277c9903620d75f0ddc179'/>
<id>urn:sha1:4bfe20f88b93645f9a277c9903620d75f0ddc179</id>
<content type='text'>
- sort [-r] [-n]: everything in RAM, which bounds it - a task has 8 KiB and a
  file caps at 3 KiB, so 96 lines of 2400 bytes with room for the stack.
  Insertion sort over an index; at that size the simple thing is fast and
  costs far less ROM. `uniq -c f | sort -n -r` finally works.
  NB the key needs a scratch SLOT of its own: gt() compares table entries, so
  reusing slot i as scratch overwrites the entries being shifted into it.
- cmp: reports the first differing byte, silent when the files match. There
  was no way to check a copy on the machine - you had to haul both to a host.
- strings [-n N]: pairs with xxd now that /bin/&lt;prog&gt; is readable. It finds
  "gbos sm83 (Game Boy Color)" inside /bin/uname.
- du [dir]: 256-byte blocks, the unit df and the filesystem use. Bytes
  overflow - /bin alone is 52 x 16 KiB, which wrapped a 16-bit counter and
  printed 32768. Recurses by re-opendir'ing, since the kernel keeps only one
  "directory being listed" cursor.
- time CMD: 1/64 s resolution from the tick counter. It rebuilds the child's
  command line at $A000 - which is its own argument area, so everything is
  copied to the stack first.
</content>
</entry>
<entry>
<title>term: ESC[2J, so clear(1) can actually clear</title>
<updated>2026-09-24T21:08:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T21:08:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=0ed3787ce6182a9198d67bb2b6ce556d21c7b1ad'/>
<id>urn:sha1:0ed3787ce6182a9198d67bb2b6ce556d21c7b1ad</id>
<content type='text'>
The terminal understood SGR and nothing else, so there was no way to erase the
screen - printing 18 newlines only scrolls the history up out of sight.
term_erase_all blanks the buffer, resets the colours, homes the cursor and
repaints; the CSI parser routes 'J' to it, and clear(1) is four putc calls.
</content>
</entry>
<entry>
<title>fs: hard links - ln(1), and rm that removes a NAME</title>
<updated>2026-09-24T21:08:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T21:08:35Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=1430515ad419f2fddeb0265337a0da043901411b'/>
<id>urn:sha1:1430515ad419f2fddeb0265337a0da043901411b</id>
<content type='text'>
gbfs has carried I_NLINK since the beginning and nothing ever incremented it,
so a file could only have one name. SYS_LINK adds a second directory entry
pointing at the same inode: the data is stored once, and both names read it.

That makes rm's job different. It now decrements the link count and only frees
the inode and its blocks when the LAST name goes - removing one of two links
used to free blocks the other still pointed at.

Refused, with the reasons that matter here: linking a directory (it would make
a cycle the tree walkers cannot survive), an existing name, and anything under
/bin, which is ROM.

The syscall takes a request block because the trap needs HL for its dispatch
table - and ln copies both paths to the stack first, since that block is a
static living at $A000, on top of the command line it is reading.
</content>
</entry>
<entry>
<title>usr: touch, tr, rev, cut, uniq</title>
<updated>2026-09-24T20:45:04Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T20:45:04Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=4cd933009dd6ce87fe710acfca6b1f786d51040c'/>
<id>urn:sha1:4cd933009dd6ce87fe710acfca6b1f786d51040c</id>
<content type='text'>
    /# ls -l /bin &gt; b
    /# cut -d \s -f 1 b | uniq -c
    52 -

- touch FILE...: gbfs has no timestamps (an MBC5 cart has no RTC), so touch
  does the half that means something here - make an empty file. O_APPEND is
  exactly right: it creates, and it does not truncate what is already there.
- tr [-d] SET1 [SET2]: ranges (a-z), -d to delete, and a short SET2 padded
  with its last character, like tr(1). ASCII only - a 128-entry map.
- rev, uniq [-c]: one line each, from a file or stdin like the other filters.

Two things the tests caught. A bare "-" is a SET, not a flag, so `tr . -`
has to skip flag-matching only when a letter follows the dash. And uniq
compared an empty "line" against the last run at EOF and flushed a phantom
blank one - a stray "1" in uniq -c output.

tr and cut also take \s \t \n \r escapes, because the shell has no quoting:
`-d ' '` arrives as three tokens, so a space or tab delimiter was simply
untypeable. Shell quoting would be the better fix; this makes the tools
usable today without touching the parser.

Banks 49-53; 10 program slots left in the 1 MB ROM.
</content>
</entry>
<entry>
<title>fs: mount the builtins at /bin</title>
<updated>2026-09-24T12:15:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T12:15:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=50a56b48f22cd31868403f1fde10cf8367344e2e'/>
<id>urn:sha1:50a56b48f22cd31868403f1fde10cf8367344e2e</id>
<content type='text'>
The commands only existed in the kernel's program table, so the only way to
find out what the machine could do was to already know (or read the source).
They are now a directory in the tree:

    /# ls /                      /# xxd -l 16 /bin/uname
    bin                          0000: cd 6b 43 06 00 0e 00 f7 .kC.....
    readme                       0008: 18 fe 47 0e 03 f7 c9 21 ..G....!
    /# ls -l /bin | head -2
    -  1 16384 worker

/bin is synthetic, not disk: dir_find resolves "bin" in the root to BIN_INO
and names inside it through the NameTable, so `ls /bin` lists exactly what the
shell can run - one table, one truth, no second list to keep in sync. The root
listing carries "bin" as a synthetic first entry so it is discoverable by
walking the tree.

A program image is inode BIN_FILE|progid: it stats as a 16 KiB file (a program
IS its ROM bank) and reads through rom_getb, which maps that bank, takes the
byte, and maps the CALLER'S text bank back before returning - the caller is
itself executing from $4000, so leaving the wrong bank mapped would return
into another program's code. Verified byte-for-byte against the host's view of
the blob. The tree is read-only: create/remove/write inside /bin all fail.

xxd grows -l (stop after N bytes), because a program image is 2048 dump lines
- more than a pipe's temp file can hold, let alone the screen.

Two traps worth remembering, both found the hard way here: `and BIN_FILE`
destroys A, so the inode must be reloaded before inode_ptr (missing it once
made ls -l stat inode 0 and call every file an empty non-directory); and ls
now copies its directory argument to the stack first, because stat()'s request
block is a static living at $A000 - on top of the command line it was reading.
</content>
</entry>
<entry>
<title>usr: xxd - hex dump a file or stdin</title>
<updated>2026-09-24T11:56:42Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T11:56:42Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=0fa1b946ec5fd3f5cba49215ea9ab62c4004aa53'/>
<id>urn:sha1:0fa1b946ec5fd3f5cba49215ea9ab62c4004aa53</id>
<content type='text'>
Eight bytes per line rather than the usual sixteen: offset + 8 hex pairs +
the ASCII column is 38 of the terminal's 40 columns, where 16 would wrap
every line and make the dump unreadable. -c overrides it.

No -s (skip): there is no seek syscall, so it streams from the start - the
same reason tail(1) has to keep a ring.

    /# xxd h
    0000: 48 65 6c 6c 6f 20 67 62 Hello gb
    0008: 6f 73 0d 0a             os..

blk(1) could already peek at raw disk blocks; nothing could look inside a
file. Verified against known bytes, from stdin, with -c, through a pipe, and
on a missing file.
</content>
</entry>
<entry>
<title>ircd: enforce unique nicknames (433), announce renames, fold case</title>
<updated>2026-09-24T11:09:34Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T11:09:34Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=a73911fcbc5d7d6910d1edcdca38230a519361ca'/>
<id>urn:sha1:a73911fcbc5d7d6910d1edcdca38230a519361ca</id>
<content type='text'>
NICK overwrote the nick with whatever was asked for, so two clients could
both be "alice": /NAMES listed her twice and a private message only ever
reached whichever slot came first in the table.

- A nick already held by another client is refused with 433
  ERR_NICKNAMEINUSE and the client keeps the one it had. Unregistered
  clients simply stay unregistered until they pick a free one, which is what
  every client already expects.
- A NICK from a registered client is a rename: it is announced to the client
  and to its channel, so nobody's user list goes stale.
- IRC names fold case, so "Alice" is "alice" - for the uniqueness check, for
  PRIVMSG by nick, and for channels ("#GB" is "#gb"). One irceq() replaces
  the exact-match compare that was used for all three.

gbos's own irc(1) already retries 433 by appending '_', so two Game Boys with
the default nick now sort themselves out: verified with a GB running ircd, a
laptop client holding "sl0pboy", and a second GB arriving as "sl0pboy_" -
both in #gbos, messages crossing both ways.
</content>
</entry>
<entry>
<title>usr: ls -l, df, mv, tail, tee, sleep, help, poweroff</title>
<updated>2026-09-24T10:48:51Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T10:48:51Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=65b53de14dc6918db39b6ee6dc032f805182daad'/>
<id>urn:sha1:65b53de14dc6918db39b6ee6dc032f805182daad</id>
<content type='text'>
Filling in the core utilities the box was missing:

- ls -l: type, links and size, now that stat() exists. The screen is 40
  columns, so it is "d  1   256 name", not the GNU sprawl. Sizes were
  previously unknowable except one file at a time with wc -c.
- df: blocks and inodes, used and free, on the 32 KiB cart disk.
- mv: no rename syscall, so it is cp + rm - and the copy has to finish before
  the source goes, or a failure loses the file.
- tail [-n N]: no seek syscall, so it streams the input through a ring of N
  lines rather than jumping to the end. N is capped by that ring.
- tee: the missing half of a pipeline - `cat big | tee copy | wc -l`.
- sleep: the thing every script needs; the kernel counts 1/64 s ticks.
- help: lists every command the shell knows, walked out of the kernel's own
  name table. The box previously had no way to tell you what it could do.
- poweroff: the only safe way to stop a gbos that has data on it. The cart's
  battery RAM is written when the emulator exits cleanly, so killing it loses
  the whole filesystem - it comes back "formatted". Found while testing /rc.
</content>
</entry>
<entry>
<title>sh: run scripts, and &gt;&gt; redirection</title>
<updated>2026-09-24T10:48:42Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T10:48:42Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=62c392178e774c09e42927cfdd0d4daebcd185ce'/>
<id>urn:sha1:62c392178e774c09e42927cfdd0d4daebcd185ce</id>
<content type='text'>
`sh &lt; file` already executed the file - and then powered the Game Boy off,
because the read loop treated EOF as "the machine is done". That is right for
init and wrong for everything else, so scripts were unusable.

A shell now asks isatty(): with stdin on a file it prints no prompt, does not
echo the script back, and exits at EOF. Only pid 1 on the console still powers
down. init also runs /rc at boot if the cart has one, so a Game Boy can come
up with its own startup script - verified across a real power cycle (write
/rc, poweroff, reboot the same cart, watch init run it).

`&gt;&gt;` appends instead of truncating, which is what makes writing that script on
the device possible in the first place.
</content>
</entry>
</feed>
