<feed xmlns='http://www.w3.org/2005/Atom'>
<title>gbos.git/src, 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/src?h=master</id>
<link rel='self' href='https://git.sl0p.foo/gbos.git/atom/src?h=master'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/'/>
<updated>2026-09-24T21:08:46Z</updated>
<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>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>kernel: stat/fsstat/isatty syscalls, and O_APPEND</title>
<updated>2026-09-24T10:48:37Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-24T10:48:37Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=dc9dffb9069e32a1a0cf14883176544b234fc3e5'/>
<id>urn:sha1:dc9dffb9069e32a1a0cf14883176544b234fc3e5</id>
<content type='text'>
Four things userland had no way to ask for:

- SYS_STAT: type, link count, size and inode for a path. The trap needs HL
  for its dispatch table, so a call taking both a path and an output buffer
  passes a request block - the shape SYS_NET already established. usr/stat.h
  wraps it, header-only like sock.h, because the block must live in the
  caller's RAM.
- SYS_FSSTAT: blocks/inodes, total and free. The bitmaps are already cached
  write-through in fs_bmbuf, so this is the popcount boot_fs already prints.
- SYS_ISATTY: is this process's stdin the console? The shell needs it to tell
  an interactive session from a script.
- O_APPEND: open a file positioned at EOF. There is no seek syscall, so this
  is the only way to add to a file - without it nothing running ON the Game
  Boy can build a multi-line file, shell scripts included.

Together these back ls -l, df, and `&gt;&gt;`.
</content>
</entry>
<entry>
<title>term: write the palette attribute for every re-rendered tile, not just the cursor's</title>
<updated>2026-09-23T14:29:47Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-23T14:29:47Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=364ecdd32f0cecb83983ca3e4a2acaac4c3251c7'/>
<id>urn:sha1:364ecdd32f0cecb83983ca3e4a2acaac4c3251c7</id>
<content type='text'>
Reported as "the nickname w00t renders with its first and last letter a
different colour". It was neither the nick hash nor the Fano palette tables:
the colour buffer was right, the tile pixels were right, and the tilemap
ATTRIBUTE - which selects the palette - was stale.

render_tile writes pixels and refreshes the attr cache, but only
update_cursor_attr ever pushed an attribute into the tilemap, for the tile
under the cursor. A batched write (sys_write, i.e. every puts()) renders its
whole dirty span through render_tile and then updates exactly one attribute,
so the rest of the line kept whatever palette its row was created with -
palette 0, whose colours are white, red and cyan. Hence: ordinary white text
was always fine, red and cyan were fine by luck, and green/yellow/blue/
magenta silently rendered as red or cyan. In "&lt;w00t&gt;" the brackets are
putc'd (attribute written, w and t correct) while the nick is puts'd, so the
middle "00" kept the stale palette and came out red.

update_tile_attr now does that work for any (row, tile col) and term_write_end
calls it for every tile it renders. update_cursor_attr is a thin wrapper.

NB the wrapper MUST stay immediately after render_cursor_tile: that path falls
through rather than calling, and render_tile clobbers B/C, so the row/column
have to be re-read from wCurRow/wCurCol. Putting update_tile_attr there first
had it inherit garbage and scribble attributes over unrelated rows - visible
as yellow text in the boot log.

Verified by decoding recorded frames (control-socket "record", which needs a
display run - --headless produces no frames): ansi(1)'s word line now renders
red/green/yellow/blue/magenta/cyan/white instead of red/red/cyan/red/cyan/
cyan/white, the per-character bar and RAINBOW! still cycle correctly, and
&lt;w00t&gt; is uniformly blue between white brackets (was blue/red/red/blue).
Pre-existing: the same capture reproduces on c9f61fc, before this session.
</content>
</entry>
<entry>
<title>usr: ircd - an IRC server running on the Game Boy</title>
<updated>2026-09-22T19:54:13Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-09-22T19:54:13Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/gbos.git/commit/?id=0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495'/>
<id>urn:sha1:0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495</id>
<content type='text'>
    /# ircd &amp;
    host$ irssi -c 10.0.0.2       /join #gbos
    other GB$ irc 10.0.0.2 gb2    /join #gbos

Implements the subset a real client needs to get in and talk: NICK, USER,
PING/PONG, JOIN, PART, PRIVMSG/NOTICE (to a channel or to a nick), NAMES and
QUIT, with the 001/004/375/372/376 numerics clients wait for on registration,
353/366 on join, and 421 for anything else. One channel per client keeps the
bookkeeping in fixed-size tables; this is a handheld with 8 KiB of task RAM.

MAXCL is 3, bounded by the kernel socket table (listener + client each take a
slot, the rest left for other programs). A fourth client is refused and the
daemon keeps serving - which is how the ~30s close() stall turned up.

Every event is logged to the LCD, so the Game Boy shows its own server
traffic: "+ alice", "alice joined", "&lt;alice&gt; hello".

Verified with real clients over the hub: two and three simultaneous hosts
registering, joining, channel relay (sender not echoed to itself), private
messages by nick, PING/PONG, unknown-command numerics, QUIT propagation, the
overflow refusal, and a second Game Boy running gbos's own irc(1) in the same
channel as a laptop client - messages crossing both ways between them.
</content>
</entry>
</feed>
