aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAge
* term: ESC[2J, so clear(1) can actually clearuser2026-09-24
| | | | | | | 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.
* fs: hard links - ln(1), and rm that removes a NAMEuser2026-09-24
| | | | | | | | | | | | | | | | | | 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.
* usr: touch, tr, rev, cut, uniquser2026-09-24
| | | | | | | | | | | | | | | | | | | | | | | | | /# ls -l /bin > 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.
* fs: mount the builtins at /binuser2026-09-24
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* usr: xxd - hex dump a file or stdinuser2026-09-24
| | | | | | | | | | | | | | | | | 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.
* ircd: enforce unique nicknames (433), announce renames, fold caseuser2026-09-24
| | | | | | | | | | | | | | | | | | | | | 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.
* usr: ls -l, df, mv, tail, tee, sleep, help, poweroffuser2026-09-24
| | | | | | | | | | | | | | | | | | | | 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.
* sh: run scripts, and >> redirectionuser2026-09-24
| | | | | | | | | | | | | | | `sh < 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). `>>` appends instead of truncating, which is what makes writing that script on the device possible in the first place.
* kernel: stat/fsstat/isatty syscalls, and O_APPENDuser2026-09-24
| | | | | | | | | | | | | | | | | | | 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 `>>`.
* term: write the palette attribute for every re-rendered tile, not just the ↵user2026-09-23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | cursor's 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 "<w00t>" 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 <w00t> is uniformly blue between white brackets (was blue/red/red/blue). Pre-existing: the same capture reproduces on c9f61fc, before this session.
* usr: ircd - an IRC server running on the Game Boyuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | /# ircd & 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", "<alice> 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.
* net: one socket per client - four-tuple demux, spawning accept, bounded closeuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A server could only ever hold ONE connection, which makes an ircd pointless: listen() turned the listener into the connection, and net_find_tcp demuxed on local port alone, so two clients on 6667 were indistinguishable. - net_find_tcp now matches an established socket on the full four-tuple (local port + peer IP + peer port) and only falls back to a LISTENing socket when none matches. - A SYN at a listener no longer consumes it: tcp_spawn_conn clones a new socket (local port + owning pid, so exit/kill still reclaims it) and the listener keeps listening. accept() returns that socket; closing it leaves the listener alone. A full table drops the SYN, and the client's retransmit is taken once a slot frees. - tcp_peer_is_current preserves BC: net_find_tcp scans with the socket index there, and the compare needs BC for the rx buffer base. Without this the scan died at the first mismatching socket, so a second client's handshake ACK never reached its socket and it hung in SYNRCVD - tcpdump showed our SYN+ACK going out and the client's NICK retransmitted five times into silence. - listen() only conflicts with another LISTENER on the port; connections share it by design now. - MAX_SOCKS 4 -> 6 (listener + 3 clients + 2 spare; 537 bytes of WRAM0 still free), and SK_ACC marks a spawned connection as not yet accepted. tcp_close's settle is also time-boxed to ~250 ms of real time. It counted PUMPS (8 x 8000), which is ~30s of wall clock when the peer never answers - invisible with well-behaved peers, but a daemon refusing a client that stayed connected froze itself, and every other client with it, for half a minute. httpd keeps its listener open across requests now, so the next client's SYN is accepted immediately instead of being dropped between close and re-listen: three parallel clients go 0.1/1.1/2.1s -> 0.4/1.0/0.1s.
* build: rebuild every object when include/*.inc changesuser2026-09-22
| | | | | | | | | The pattern rule listed no header dependency, so editing a constant in include/gbos.inc rebuilt only the .asm files that had themselves changed. Bumping MAX_SOCKS left the kernel half-built against the old value - a socket table sized 6 in socket.asm while dmesg still printed "4 sockets" - and the resulting misbehaviour looked exactly like a logic bug in the new socket code, which cost real time to chase.
* sched: SchedYield must preserve HL - link-less boots warm-rebooted every ~11suser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported by an outside agent (BUG-schedyield-clobbers-hl.md) while putting gbos in a browser, where there is no link port at all. Verified here, fixed, and re-verified. SchedYield had two exits. The switch path preserved registers because hSwitchTo saves and restores the full context. The "nobody else is runnable" path was a bare `ret c` straight out of FindNextReady, which leaves HL pointing into wProcTable (PcbPtr puts it there). Two callers - net_op_recv's timeout loop and tcp_connect's - keep a pointer in HL across that call: ld hl, wNetTO inc [hl] jr nz, .wait call SchedYield ; HL now = &wProcTable, not &wNetTO inc hl inc [hl] ; stray increment into a PCB With no DHCP server the boot-time `dhcp` spins in that loop for ~10s, and with the shell blocked on tty input nothing else is PS_READY - so *every* yield took the no-switch path and corrupted a byte, until a saved return address rolled from $00xx to $01xx and RET landed on the cart entry $0100. A clean-looking warm reboot, every ~11s, forever. It never happens with tools/gbhub running, which is why it survived this long. Fixed in the scheduler rather than at the two call sites, so the contract holds for every present and future caller: SchedYield now preserves AF/BC/DE and HL on both paths. On the switch path the pushes sit on the task's own stack and are popped when it is rescheduled. Evidence, with the link port unconnected (no --serial-sock; note --headless bypasses the emulator's breakpoints, so these run on the display path): before: breakpoint $0100 hit 5x in 40s, first at cycles=46,286,496 (matching the report), deltas ~45.5M cycles apart; HL at the `inc hl` after the call read $C000 instead of $C9D1 after: 0 hits in 60s, HL reads $C9D1 every time, and a 60s screen watch shows no spontaneous reboot (it reproduced at 9.9s before) No regression: 34/34 across the tool, httpd and client suites.
* usr: httpd - the Game Boy serves its filesystem over HTTPuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | /# httpd & host$ curl http://10.0.0.2/ -> a listing of the cart RAM host$ curl http://10.0.0.2/readme -> the file, from battery-backed SRAM GET only. "/" serves /index.html if present, else a generated listing; any directory lists with every entry linked, so you can click around a 1997 handheld's disk from a browser. Content type from the extension. No Content-Length: HTTP/1.0 + Connection: close delimits the body by closing, which suits a stack with one in-flight segment. All output goes through one 200-byte buffer, so each net_send is a full segment instead of one tiny segment per string. It drains the whole request, not just the request line. That is not politeness: leaving the rest unread means those segments are never acknowledged, so the client retransmits headers for a minute, never sends its FIN, and its dead connection keeps arriving at port 80 long after we re-listen. One curl with 1.4 KB of headers stalled every following request for 30-120s. It deliberately does NOT read the keyboard either. Backgrounded - the useful way to run it - every key it polled would be a key stolen from the shell's prompt. It is a daemon: kill(1) stops it, and the kernel reclaims its socket. Verified with curl: index, byte-exact file bodies, nested paths, directory listings, 404, 501, a 1.4 KB request spanning segments, browser user-agents, three parallel clients (0.1/1.1/2.1s), the shell staying usable alongside it, and six kill/restart cycles - more than there are sockets.
* net: passive open (listen/accept) + own sockets + close promptlyuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The stack could only ever dial out. Two new ops give it an ear: - listen(sock, port): sockets demux on LOCAL PORT alone, so the listener IS the connection - the SYN fills in the peer and the socket becomes the connection. One at a time, re-armed by listening again after close, which is all a 4-socket table can honestly promise. A second client's SYN is dropped in silence; its retransmit is taken once we are free (~1s), so three parallel clients complete in 0.1/1.1/2.1s. - accept(sock): one RX pump per call, never blocks (like recv_nb), so a server keeps its own main loop and stays killable. - TCP_LISTEN/TCP_SYNRCVD in tcp_in: answer a SYN with SYN+ACK, become established on the handshake's ACK, and process any data that ACK carries. tcp_peer_is_current tells a peer's retransmitted SYN from a newcomer's. Without it, "any SYN in SYNRCVD is a retransmission" let a second client *hijack* a connection mid-request: the first client got its SYN+ACK, sent its GET twice, and was never answered (found in a tcpdump of two parallel curls). listen() also refuses a port another socket already holds - two sockets on one port silently black-hole the second listener, since demux takes the first match. Sockets now record the pid that opened them (SK_OWNER) and exit *and* kill release them: with 4 slots, a few killed irc/nc/httpd runs used to leave the box unable to open a socket at all until reboot. tcp_close stops settling the moment the peer's FIN/ACK arrives instead of burning its whole pump budget. It otherwise sits in FINWAIT for about a second, and a server that closes and re-listens drops the next client's SYN in that window: every request after the first paid a SYN retransmit. 1.1s -> 0.1s per request.
* kernel: per-process sleep - two sleepers used to deadlock each otheruser2026-09-22
| | | | | | | | | | | | | | | | | sys_sleep kept its entire state in three globals (wSleepTarget, wSleepAcc, wSleepLastDiv) and yielded inside its own loop. With two processes sleeping at once, each zeroed the other's accumulator on entry, so neither ever reached its target: both slept ~forever. Nothing hit it while only one program at a time paced itself. `httpd &` (polling accept) plus a foreground `ping` is enough: ping wedged with its echo reply already sitting in the socket buffer, unread. Now the deadline lives in registers - i.e. on the caller's own stack across SchedYield, which makes it per-process by construction - and it counts the 64 Hz tick (wTicks, via TimerISR) instead of accumulating DIV deltas. ticks16 re-reads the counter until the high byte is stable, so a carry between the two byte reads can't report a 256-unit jump.
* fs: bounds-check direct blocks - a file over 2 KiB ate the next inodeuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | sys_getb/sys_putb indexed inode.blocks[pos/256] with no limit at all, so a file that grew past its pointers just kept walking: first through the 4 unused bytes at the tail of the 16-byte inode, then straight into the NEXT inode, reading its type/nlink/size as block numbers. It hides well. Reads and writes alias identically, so a big file can be written and read back byte-for-byte and look fine - until something else touches the neighbouring inode, after which the tail of the file is garbage from whatever block those bytes now name. Found by serving a 7 KB file over httpd: corruption began at exactly offset 3072, and only sometimes. - NDIRECT 8 -> 12: bytes 4..15 are all block pointers now, which is what the runaway indexing was already doing by accident. Files go to 3 KiB, no on-disk layout change, no format bump. - getb past the last direct block reports EOF; putb drops the byte like a full disk. A capped file beats a corrupted neighbour. count 250 > big (7142 bytes) now stops at 3072 and its neighbours survive.
* usr: grep, more, cp, nc - and make pollcon pump the linkuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The shell has had pipes for a while with nothing to pipe *through*, and an 18-row screen with no way to stop output scrolling off it. - grep [-vin] PAT [file]: substring match (no regex), stdin or a file, grep(1)'s exit convention. The other end of the pipe, at last. - more [file]: pages 17 lines at a time, --More-- bar in white-on-blue, any key pages, q quits. Counts *screen* rows, so wrapped lines pay their real cost. Keys come from pollin/pollcon, not stdin, so `cat big | more` still has a keyboard. - cp SRC DST: the fs has rm/mkdir but no way to duplicate a file. mv is this plus rm (there is no rename). Rejects SRC == DST, which would otherwise truncate the source via O_WRITE before reading a byte. - nc [-s] HOST PORT: raw TCP. Interactive (START sends the line with a CRLF, /q quits) or -s to pump stdin and print the reply with its own wall-clock idle timeout, because the kernel's blocking recv counts pumps, not seconds. Generalizes what chat/irc hardcode: `nc towel.blinkenlights.nl 23` works. sys_pollcon now pumps the link before draining the console ring. The ring is filled *by* net_pump, so a program that never touches the network polled a ring nothing would ever fill: host-injected keys (gbtype/gbdemo) hung `more` forever, and the Game Boy stopped answering pings for as long as it ran. KGetc already pumps for this reason. New tools take banks 35-38. Locals over statics in all four: a program's _DATA starts at $A000, where the shell leaves the command line.
* net: three TCP receive bugs - caller corruption, data loss, early EOFuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Found by writing nc(1): a 96-byte receive buffer against a host that answers in 200-byte segments hung the process every time. - recv/recv_nb copied RXLEN bytes into the caller's buffer and never looked at the caller's maxlen, so any short buffer got overrun and its stack smashed. Deliver min(RXLEN, maxlen) and keep the tail in RXBUF for the next call. wget only survived because its buffer (220) happens to exceed our MSS. - tcp_buffer_data overwrote RXBUF from offset 0 on every segment, so a segment arriving before the app drained the previous one destroyed unread bytes - silent, undetectable data loss. Append at RXBUF+RXLEN instead, and drop (without advancing rcv_nxt) when it doesn't fit, so the peer retransmits once there's room. RXLEN is one byte, so 255 is the capacity that matters, not RXBUF's 288. - A draining recv now clears RXLEN as well as HASRX: RXLEN is the append offset, and a stale one made every later segment look too big to fit, stalling the connection until the peer gave up. - A FIN past a gap is not end-of-stream. tcp_in accepted any FIN whose segment carried no data, without a sequence check, marking the socket DONE and making recv report EOF at the hole - truncating a transfer right before its last segments. Decide in-order-ness once, before rcv_nxt moves, and re-ACK an out-of-order FIN instead. Verified against a 6300-byte reply received while sending 831 bytes: byte-exact (6339 = payload + banner, 303 lines, first/mid/last intact), plus no regression in ping / nslookup / wget / irc.
* sh: background dhcp at boot so the prompt is never blockeduser2026-09-22
| | | | | | | | dhcp's recv times out only after ~250*65536 pumps x3 retries, so fork()+wait() left the screen stuck at 'dhcp: discovering' with no prompt whenever no DHCP server answered (raw emulator, no bridge). Fork without waiting: the prompt is immediate, the lease (or 'no offer') lands a moment later, and the job is reaped by the main loop.
* demo: boot the SL0PBOY-forked BIOS in the README gifuser2026-07-18
| | | | | | | gbdemo resolves a bare --bios to bios/sl0pboy_bios.bin when present (else stock gbc_bios.bin). Generate the fork with the emulator's tools/mk_sl0pboy_bios.py. The gif now opens on the SL0PBOY boot animation - logo drop, colorize, chime - before gbos comes up.
* term+libc: batched write rendering - one tile render per write(), 2.3x linesuser2026-07-18
| | | | | | | | | | | | | | | | | sys_write sets wBatch; render_cursor_tile - the single choke point all of term_putc's visible mutations pass through - then marks a dirty span (per LINE-SLOT, so spans survive scroll rotation for free) instead of rendering. term_write_end renders every dirty tile exactly once, then the cursor. Scrolls stay immediate (blank rebuild is cheap and artifact-free); file-bound writes flush for free. The enabler is in libc: puts() was a SYS_PUTC trap PER CHARACTER, so almost no console output ever went through sys_write. It now issues one SYS_WRITE for the whole string - one trap, one batch, and every line-printer (echo, irc, the shell) gets the batched path. Measured: 37-char write = 128k cycles (3.5k/char) vs ~8k/char down the per-char trap path. Full demo passes; GIF regenerated.
* demo: boot through the CGB BIOS + double-speed ROM in the README gifuser2026-07-18
| | | | | | | | | spawn --bios shows the real power-on show (logo drop + splash) before gbos boots; gbdemo resolves a bare --bios to the emulator's bundled boot ROM by absolute path (the tmux pane cwd is wherever the user is). The screen-synced waitfor absorbs the extra ~4s - only the first prompt timeout got a bump. Regenerated with the double-speed kernel: same demo, boots and scrolls visibly snappier.
* kernel: CGB double-speed mode - 2x everything (1.99x measured)user2026-07-18
| | | | | | | | | | | | | | KEY1 prepare + STOP at KernelInit (skipped on warm reboot if already fast). The PPU/LCD keep their normal rate; the timer and DIV double with the CPU, compensated at the two consumers: - TimerISR now fires at 128 Hz; a toggle byte keeps wTicks at 64 Hz (uptime semantics unchanged) - sys_sleep halves the DIV-delta accumulator's high byte (256 DIV ticks = 1/128 s in double speed) so gsleep/msleep stay real-time Measured: count 60 (scroll-heavy) 3.77s -> 1.89s wall; wTicks 64 Hz over 4s; ping's 800ms msleep pacing unchanged (2.77s for 4 echoes). sl0pboy already emulated KEY1/STOP + per-speed timer/PPU rates.
* term: latch SCY in VBlank - fix ring-scroll shear on live displaysuser2026-07-18
| | | | | | | | | | | | | | The ring-scroll wrote SCY mid-frame; SCY is sampled per scanline (on hardware and in sl0pboy's PPU), so a scroll landing mid-frame rendered the top of the frame at the old offset and the bottom at the new one - a one-frame shear, invisible in frame-sampled GIF captures but ugly on a live sixel view. term_view_update now writes hSCY (HRAM) and a transparent VBlank ISR (push af / apply / pop af / reti, same profile as TimerISR) copies it to rSCY, so the view only ever moves at frame boundaries. The scroll's tile+map writes stay immediate: the new map row is invisible at the old SCY by construction (it's the ring row one past the visible 18).
* term: SCY ring-scroll - scroll writes ONE map row; OSK toggle is 212 cyclesuser2026-07-18
| | | | | | | | | | | | | | | | | | | | | The BG map's 32 rows become a ring: logical row L always lives at map row (wMapTop + L) & 31, and SCY = (wMapTop + wViewTop)*8 places the view. Scrolling bumps wMapTop, rebuilds the one new bottom line and writes its single map row (map_row_one); the other 17 rows move for free. The whole OSK view dance - shift the cursor above the keys, restore the backlog on hide - is now term_view_update: one SCY write, no map rewrite at all. OSK safety: the keyboard lives on the WINDOW layer, which SCY never moves. Stale ring rows can only appear under it: wViewTop = max(0, wCurRow-14) <= 3 is nonzero only while the OSK is visible, and the window covers exactly those bottom 3 rows (WX=7, WY=120). Verified: scroll + view shift + scroll-while-OSK-up + backlog restore all pixel-correct via VRAM screenshots; full README demo passes. term_scroll: 78.5k -> 35.6k T-cycles (857k pre-optimization: 24x). OSK toggle: 45k tilemap rewrite -> 212 cycles. Regenerated demo/gbos-demo.gif on the new renderer.
* term: 3 more renderer wins - 10.9x scroll, 2.7x putc vs pre-cacheuser2026-07-18
| | | | | | | | | | | | | | | | | | | | | | | 1. color_setup memoization: a call with the same (colL,colR) pair as last time returns immediately (masks/attr still valid in WRAM). Runs of same-colored cells - i.e. almost all text - hit this. 2. build_tile blank fast path: two spaces = bg-only planes, 8 constant rows; term_scroll's cleared line and every blank region skip the glyph pipeline entirely. 3. build_tile two-phase rewrite: combine both glyphs into wRowBuf with pointers in registers (the old per-row 16-bit WRAM pointer walk was most of the blit), then compose planes unrolled with plane-0 masks in B/C. 4. term_write_tilemap attr pass: split each row at the pos-256 VRAM bank boundary into two tight cache->tilemap copy runs - no per-tile addressing or bank test. Measured (emulator cycle counter, ANSI test screen): term_scroll 857k -> 248k (attr cache) -> 78.5k T-cycles term_write_tilemap 723k -> 114k -> 45.4k term_putc 18.2k -> 6.7k A scroll is now ~1.1 frames of guest CPU (was 12); a full 40-char line prints in ~63ms of guest time (was ~173ms).
* term: cache tile attrs in COL_BUF's spare stride bytes (3.5x scroll)user2026-07-18
| | | | | | | | | | | | | | term_write_tilemap's attribute pass ran color_setup - the full Fano palette walk - for all 360 tiles on every scroll. render_tile already computes the attr when a tile changes, so store it (COL_BUF + slot*64 + ACACHE + tcol; the stride's 24 spare bytes were free) and the attr pass becomes a table read. update_cursor_attr reads the cache too. term_init now redraws before writing the tilemap so the cache is warm. Measured entry-to-return with the emulator cycle counter, ANSI test screen content: term_write_tilemap 723k -> 114k T-cycles (6.3x), term_scroll 857k -> 248k (3.5x) - a scroll drops from ~12 frames of guest CPU to ~3.5.
* docs: drop the link-drop investigation writeupuser2026-07-18
|
* hooks: clang-format pre-commit (changed lines only) + .clang-formatuser2026-07-18
| | | | | | | | | Same setup as sl0pboy: git-clang-format scopes formatting to the lines each commit touches, re-adds the formatted files, and refuses partially-staged C files. Style file matches the house style (4-space, attached braces, 100 cols, short cases/ifs inline, no include sorting - order is load-bearing for SDCC userland). Enable once: git config core.hooksPath hooks
* docs: concise README (networking + demo gif up front); internals.mduser2026-07-18
| | | | | | | | | README is now pitch -> proof (real capture gif) -> quick start -> docs pointers, with the network stack and gbhub/gbjoin/gbtype getting explicit billing. The full technical breakdown (memory model, syscall ABI, fork/exec/context switch, FS layout, toolchain war stories) moves to docs/internals.md, plus previously-undocumented sections on the network stack and the terminal/OSK.
* tools: gbdemo - scripted, screen-synced demo driver (+ the README demo)user2026-07-18
| | | | | | | | | | | | | Runs a step file: spawns a GB on the gbhub network, records the LCD to a .gbv over the emulator control socket, injects console text (hub ctl, like gbtype) and button events (gbctl) at the right moments, then renders a GIF (gbgif.py). Sync is waitfor/waitgone on the terminal's WRAM shadow (TERM_BUF $D600 + wAssign row map) read via the emulator's debug socket - steps key off real screen output, so boot/DHCP/IRC timing can vary without breaking the choreography. demo/readme.gbd is the README GIF: cold boot -> uname -> ping -> IRC session on 10.0.0.1.
* term: 7-color terminal (per-glyph fg+bg) + ANSI escapes; colorize ircuser2026-07-18
| | | | | | | | | | | | | Fano-plane palette scheme: the 7 colors map onto 7 CGB BG palettes so any color pair shares one palette; a tile's palette is picked from the set of colors its two cells need (tables generated by tools/gencolor.py). Per cell a packed (bg<<4)|fg byte lives alongside the char shadow, and the glyph blitter steers glyph/empty pixels to each cell's fg/bg color slots via plane masks. An ANSI-ish CSI parser (ESC [ .. m) drives it; usr/ansi.c demos it and the irc client now renders hashed nick colors, status dimming and a channel-activity bar.
* kernel: a real timer source (64 Hz tick IRQ) + uptime(1)user2026-07-18
| | | | | | | | | | | | | | | | | | | | | | | | | | The kernel had no clock: TimerISR was a reti stub, IEF_TIMER masked, and IME was never enabled - the vectors were decorative. sys_sleep just polls DIV deltas per-process; nothing counted globally. Now: TAC runs the hardware timer at 16384 Hz with TMA=0, so TIMA overflows at exactly 64 Hz; TimerISR increments a monotonic 32-bit wTicks (wraps after ~2.1 years). Scheduling stays cooperative - the ISR is transparent. Enabling IME in a kernel written for zero interrupts needs care wherever SP points into memory whose bank is being switched (an IRQ pushes onto SP): - read_block/write_block map the disk bank over the $A000 window that holds the caller's stack -> di/ei around the transfer (~2ms, well under the 15.6ms tick period, so no tick is ever lost) - hSwitchTo switches SVBK + cart-RAM banks under the outgoing stack -> di on entry, ei once the incoming stack is mapped - fork already runs on KSTACK_TOP2 (fixed WRAM) - safe as-is - term_putc's SVBK switch only remaps $Dxxx, stacks live in $Axxx/$Cxxx SYS_UPTIME (36) copies the counter (4B LE, di/ei so the read can't tear) to a user buffer; libc gticks(); usr/uptime.c formats 'up [Nd] H:MM:SS'. uptime avoids SDCC long div/shift entirely: sm83.lib modules link into their own areas that land in the $A000 RAM window (latent build.sh trap, documented there) - bytewise >>6 plus bounded subtraction loops instead.
* kernel: dmesg-style boot spew before init spawnsuser2026-07-18
| | | | | | | | | | | | | | | | | | | Print everything the kernel knows implicitly at boot, Linux-flavored, on the LCD console and mirrored over the link (gbhub logs each GB's boot): gbos sm83 microkernel console: CGB (boot a=$11) <- boot ROM's A/B, saved at entry cart: mbc5, 1M rom, 128K sram <- our own cart header ($0147-49) mem: 32K wram 16K vram 127B hram <- CGB constants proc: 8 slots, 31 programs <- link-time table sizes net: slip on link port, 4 sockets fs: gbfs v3 mounted, 120/128 blk free <- bitmap popcount; 'formatted' tty: 40x18 console, SELECT = osk on first boot init: spawning pid 1 New src/dmesg.asm with kernel print helpers (kputc/kputs/kputhex/kputdec) that bypass KPutc (no process context at boot). term_init now runs before net/fs init so the spew is visible as subsystems come up.
* refactor: c/ -> usr/, compiled program blobs out of the source treeuser2026-07-18
| | | | | | | Userland sources live in usr/ (fits the Unix theme better than 'c'). SDCC output .bin blobs land in build/usr/ with the other build artifacts instead of littering the source dir; programs.asm INCBINs them from there. Byte-identical ROM.
* tools: gbtype prints hub errors to stderr and exits 1user2026-07-18
|
* net: reject out-of-order TCP segments (accept only seq == rcv_nxt, re-ACK dups)user2026-07-18
| | | | | | | | Retransmitted segments (GB ACKs lag behind slow LCD rendering, so real servers do retransmit) were accepted as fresh data: the same line rendered again on every retransmit, and rcv_nxt over-advanced so every later outgoing segment carried an ACK beyond the peer's snd_nxt - which real stacks drop, silently wedging the session ('/join does nothing' until reconnect).
* term: dark mode - black background, white text (OSK highlight inverts to match)user2026-07-18
|
* irc: keep the keyboard live during RX floods (poll input every pass)user2026-07-17
| | | | | | | | | | | | | | | | | | | | | | '/join #sl0p right after connect' looked broken: the main loop only polled input when net_recv_nb returned nothing, and 'continue'd on every received segment. During a big MOTD (irc.sl0p.foo's is ~60 lines of color art) that starved input for the whole ~20s flood - the OSK wouldn't even open, and keystrokes were dropped. gbtype input fared a bit better (buffered in the 64-byte console ring) but still wasn't serviced until the flood ended. Poll pollin()/pollcon() once every loop iteration regardless of RX, and only idle-sleep when a pass both received nothing and read no key. The protocol/join logic was already correct (verified: JOIN #sl0p is sent, accepted, and the client switches to [#sl0p] with topic + names) - this just makes typing responsive while messages are streaming. Verified against the live server: injecting /join #sl0p *during* the MOTD flood now joins immediately instead of waiting it out. (Note: '#' is on the OSK - third row, second from the end: 10,8,":;,=+*_!?()[]<>@#~" - just not obvious.)
* irc: strip mIRC formatting, drop 004/005 noise, fix a RAM-bank-swap landmineuser2026-07-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Against the real irc.sl0p.foo (via a TLS-stripping socat proxy) the client's screen filled with garbage after connect. Two causes: 1. Color/format codes. The MOTD is a mIRC-color ASCII-art logo. We dropped the \x03 control byte (term ignores <32) but left its numeric *arguments*, so the logo rendered as digit soup ('09> stream the work', '030903 030903 ...'). strip_fmt() now removes \x03 color (+its fg[,bg] digits), \x04 hex color, and the \x02/\x0f/\x11/\x16/\x1d/ \x1e/\x1f toggles, applied to the trailing text of every line (leaving \x01 for CTCP). Standalone digits like '3 apples' are untouched. 2. The real bug: handle() defaulted pfx/txt to a read-only "" literal, and both bang(pfx) and strip_fmt(txt) write a NUL terminator into it. That literal lives in ROM (000-FFF); on MBC5 a write there is a RAM-bank-select, silently swapping the cart-RAM bank - where every static lives - out from under the program. One prefix-less or empty-trailing server line (real ircds send them: NOTICE AUTH, ERROR, registration PING) and all state turns to garbage. Fixed with a writable 1-byte 'empty' default (zeroed in main; gbos doesn't clear BSS). Also suppress 004 (MYINFO) and 005 (ISUPPORT) - pure noise on 40 cols, and InspIRCd splits ISUPPORT across several lines. Verified end to end against the live server: full InspIRCd MOTD (logo, LUSERS, links) renders clean and stable; host unit tests cover color/ bold/CTCP-action stripping and prefix-less NOTICE/ERROR/PING.
* gbhub: control socket + gbtype to inject shell input without the OSKuser2026-07-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Typing into a Game Boy meant driving the on-screen keyboard by hand. But the link port already *is* the shell's console: bytes the hub sends un-SLIP-framed land straight on stdin (that's what GB.type() has always done for spawn-mode command scripting). This just exposes it. gbhub now serves a control socket (/tmp/gbhub.ctl, world-connectable like the join socket) accepting two commands: 'list' and 'type [idx]' followed by a payload (delimited by the client's half-close). Injection is chunked and paced to respect the 64-byte kernel console ring, so arbitrarily long input - even multi-line scripts - reaches the shell without overflow. The server runs in both daemon and spawn modes. tools/gbtype is the client: tools/gbtype 'ls -l' run a command on GB0 (Enter appended) tools/gbtype -g 1 ps target the Nth joined Game Boy tools/gbtype -n abc no trailing Enter tools/gbtype -r just press Enter tools/gbtype -l list connected Game Boys printf 'ls\nuname\n' | tools/gbtype pipe a script via stdin Options precede the command; the first non-option word starts literal text, so 'gbtype ls -l' needs no quoting. No root required. Verified end to end: injected list/uname/echo through the real CTL protocol and saw the shell execute each and echo output back.
* irc: a bitchx/irssi-style IRC clientuser2026-07-17
| | | | | | | | | | | | | | | | | | | | | | | irc HOST [NICK] - connects over the kernel TCP stack (DNS-resolves the host), registers, and runs a live client on the 40x18 LCD. UI, within the terminal's means (no cursor addressing - just \r + \b): messages scroll above a fixed irssi-style input line '[#chan] text_' redrawn in place; long input scrolls horizontally. The elders' formats: <nick> msg, <nick:#c> off-channel, *nick* private, -nick- notice, * nick action, >target< outbound, -!- server/status. Keys come from both the OSK (SELECT) and the console ring (pollcon), so a hub can drive it. Commands: /join /part /msg /me /nick /quit /raw, plus bare text to the current channel. Handles PING (PONG + a wink), CTCP ACTION/VERSION, JOIN/PART/QUIT/KICK/NICK, 332/353 topic+names, and 433 nick-in-use (auto-appends _). Registers on bank 32 as program id 30. Note: gbos doesn't zero C statics, so main() inits its state explicitly; the local TCP port is randomized (DIV) to dodge a stale server-side half-open from an unclean prior exit. Tested end to end against a small ircd through gbhub: full MOTD burst, join, channel + private messages, actions, and bot replies all render correctly.
* net: SYS_POLLCON + larger console ring for link-injected inputuser2026-07-17
| | | | | | | | | | | | | | | Programs that own their main loop (the IRC client) need to poll for typed input without blocking. pollin() only sees the on-screen keyboard; bytes injected over the link (gbhub 'type', for scripted/hub-driven sessions) land in the kernel console ring, previously only drained by the blocking KGetc path. Add SYS_POLLCON: a non-blocking con_pop for userland. Also enlarge that console ring 16 -> 64. One net_pump drains an entire serial burst into the ring at once, so a whole injected command line has to fit or bytes are dropped and lines merge (a 20-char command came out truncated and glued to the next). 64 covers a full line; mask stays a power of two.
* net: fix truncated TCP field offsets corrupting multi-segment recvuser2026-07-17
| | | | | | | | | | | | | | | | | | | | | SK_STATE/SK_SND/SK_RCV sat at struct offsets 305/306/310, past the 288-byte SK_RXBUF. But the field accessors add the offset as an 8-bit immediate (add SK_x; ld l,a), so rgbasm truncated 305->49, 306->50, 310->54 (the -Wtruncation warnings we'd been ignoring) - putting STATE and the sequence numbers *inside* RXBUF at offsets 49/50/54. Any TCP segment with >=33 payload bytes therefore overwrote the connection's own STATE and rcv_nxt/snd_nxt with message text. The first segment of a stream landed, its data clobbered STATE to a garbage value, and every subsequent segment was dropped because tcp_in no longer saw ESTABLISHED - so multi-segment TCP receives (an IRC MOTD, any HTTP body past one segment) silently stalled. wget appeared to 'work' only because a single-segment reply plus a never-honored FIN still printed once. Fix: reorder the struct so STATE/SND/RCV precede the big RXBUF, keeping every field offset < 256 (SK_SIZE unchanged at 314, all accessors are symbolic). Zero -Wtruncation warnings remain. Verified an 11-line IRC registration burst now arrives intact.
* docs: link-drop investigation resolved - drops are self-healing nowuser2026-07-17
| | | | | | | | | | | | | Rewrite the notes from 'open / not root-caused' to the resolution: the socket never mysteriously dies; every producible drop signature is the emulator process dying silently (SIGPIPE on console echo after a hub restart, terminal death/HUP/stray quit keys in windowed mode - which exactly explains the 'only when idle' correlation - or a guest $ED poweroff), plus observation artifacts in the original session's tooling. Records the fixes shipped on both sides (emulator link auto-reconnect + SIGPIPE ignore + $ED logging; hub eof-vs-error logging, kept stderr, EBUSY guard), the honest spawn-mode caveat, and what's still worth doing.
* gbhub: make GB drops attributable, not spookyuser2026-07-17
| | | | | | | | | | | | Link-drop investigation fallout - every drop looked identical because the hub discarded all the evidence: - gb_reader logs *how* a GB left (clean eof vs the exception) with timestamps on join/leave, for correlating against SSH/terminal events. - Spawn mode keeps each emulator's stderr in /tmp/gbhub_<n>.err instead of devnull - postmortems get the emulator's side of the story. - A second gbhub exits with a clear 'already running?' message instead of an ioctl traceback (and can't damage the live hub's NAT/socket).
* net: unbreak ping against hosts that intermittently drop ICMPuser2026-07-17
| | | | | | | | | | | | | | | | | | | | | | | | | Pinging sl0p.foo through gbhub looked hung: DNS resolved, then nothing. Packet-tracing showed the echo request leaving the hub's TUN and eth0 correctly NATed every time - but 80.78.19.56 blackholes ICMP for all ids in windows of tens of seconds (provider rate limiting). One lost reply wedged ping for minutes because net_recv's pump-counted timeout is effectively unbounded at native emulation speed. Two fixes: - NET_RECVNB (op 8): non-blocking recv - one RX pump, $FE if nothing buffered, same delivery/EOF semantics as NET_RECV otherwise. ping now waits <=~2s per seq (net_recv_nb + msleep loop), prints 'seq=N timeout' and moves on, like real ping. - ICMP echo id was hardcoded $1234 for every GB, every boot, so all sessions produced byte-identical flows - hostile to NAT conntrack (keyed on icmp id). wNetEchoId is now our host octet + rDIV timing noise sampled at DHCP lease, distinct per GB and per boot. Verified: 8 back-to-back native-speed gbhub runs, zero hangs; a run that hit a blackhole window printed seq=1 timeout then recovered to 3/4 received. GB<->GB ping and DNS/DHCP unaffected.
* docs: investigation notes for the idle link-socket drop (open bug)user2026-07-17
|