aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 21:54:13 +0200
committeruser <user@clank>2026-09-22 21:54:13 +0200
commit0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495 (patch)
tree35d6e8713ed3ea638e5aaea04d9c726f8f2c2d49
parentnet: one socket per client - four-tuple demux, spawning accept, bounded close (diff)
downloadgbos-0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495.tar.gz
gbos-0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495.tar.xz
gbos-0a7ff09a910eaf5bcb0dc76d08dc5a6c9c239495.zip
usr: ircd - an IRC server running on the Game Boy
/# 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.
-rw-r--r--Makefile2
-rw-r--r--README.md17
-rw-r--r--include/gbos.inc1
-rw-r--r--src/programs.asm7
-rw-r--r--usr/ircd.c403
5 files changed, 425 insertions, 5 deletions
diff --git a/Makefile b/Makefile
index dd7a679..53c8f21 100644
--- a/Makefile
+++ b/Makefile
@@ -28,7 +28,7 @@ $(BUILD)/%.o: src/%.asm $(INCS) | $(BUILD)
PROGS := chello echo true false uname pid cat wc head args \
ls save rm sh ps kill spin blk mkdir count ptest necho \
wget chat netd ping nslookup dhcp irc uptime ansi \
- grep more cp nc httpd
+ grep more cp nc httpd ircd
CBLOBS := $(patsubst %,$(BUILD)/usr/%.bin,$(PROGS))
$(BUILD)/programs.o: $(CBLOBS)
diff --git a/README.md b/README.md
index 920ed1f..c84eb43 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,14 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
`nc` (netcat: `nc HOST PORT` interactively, or `nc -s HOST PORT < file` to
pipe a request in and print the reply), `chat`, and a colorized **IRC
client** (`irc HOST [NICK]`, `/join` and friends, SELECT for the keyboard).
-- **It serves, too**: the kernel does passive opens (`listen`/`accept`), and
- `httpd` puts the cart's filesystem on the web — `httpd &`, then point a
- browser at the Game Boy: files are served from battery-backed RAM and
- directories come back as clickable listings.
+- **It serves, too**: the kernel does passive opens (`listen`/`accept`, one
+ socket per client), so the Game Boy runs daemons. `httpd` puts the cart's
+ filesystem on the web — `httpd &`, then point a browser at it: files come
+ from battery-backed RAM, directories as clickable listings. `ircd` is a
+ real IRC server — `ircd &`, then `irssi -c 10.0.0.2`: NICK/USER/JOIN/
+ PRIVMSG/PING/NAMES/PART/QUIT, with the channel relayed between clients.
+ A laptop and a second Game Boy can sit in the same channel, hosted by a
+ Game Boy.
- **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server +
NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach
the real internet; `tools/gbjoin` attaches an interactive GB to it, and
@@ -59,6 +63,11 @@ tools/gbtype 'ping 10.0.0.3' # or type into it from the host
# serve the Game Boy's filesystem to your browser:
tools/gbtype 'httpd &' # then open http://10.0.0.2/
curl http://10.0.0.2/readme # files straight out of cart RAM
+
+# or host an IRC network on it:
+tools/gbtype 'ircd &' # then, from the host:
+irssi -c 10.0.0.2 # /join #gbos
+tools/gbtype -g 1 'irc 10.0.0.2 gb2' # a second GB joins the same channel
```
On the Game Boy: **SELECT** toggles the on-screen keyboard, d-pad + **A**
diff --git a/include/gbos.inc b/include/gbos.inc
index 7f4431b..eabdcec 100644
--- a/include/gbos.inc
+++ b/include/gbos.inc
@@ -306,5 +306,6 @@ DEF PROG_MORE EQU 34
DEF PROG_CP EQU 35
DEF PROG_NC EQU 36
DEF PROG_HTTPD EQU 37
+DEF PROG_IRCD EQU 38
ENDC
diff --git a/src/programs.asm b/src/programs.asm
index 31125a2..0277dcc 100644
--- a/src/programs.asm
+++ b/src/programs.asm
@@ -154,6 +154,9 @@ ProgNc:
SECTION "prog_httpd", ROMX[$4000], BANK[39]
ProgHttpd:
INCBIN "build/usr/httpd.bin"
+SECTION "prog_ircd", ROMX[$4000], BANK[40]
+ProgIrcd:
+ INCBIN "build/usr/ircd.bin"
; -----------------------------------------------------------------------------
; PROG_SH (bank 3) - the shell, now written in C (usr/sh.c): parses >/</| and
@@ -272,6 +275,8 @@ ProgramTable::
dw ProgNc
db LOW(BANK(ProgHttpd)), HIGH(BANK(ProgHttpd))
dw ProgHttpd
+ db LOW(BANK(ProgIrcd)), HIGH(BANK(ProgIrcd))
+ dw ProgIrcd
ProgramTableEnd::
; -----------------------------------------------------------------------------
@@ -355,4 +360,6 @@ NameTable::
db PROG_NC
db "httpd", 0
db PROG_HTTPD
+ db "ircd", 0
+ db PROG_IRCD
db 0
diff --git a/usr/ircd.c b/usr/ircd.c
new file mode 100644
index 0000000..b960dd6
--- /dev/null
+++ b/usr/ircd.c
@@ -0,0 +1,403 @@
+#include "sock.h"
+/* ircd [PORT] - an IRC server running on the Game Boy. Default port 6667.
+ *
+ * /# ircd &
+ * host$ irssi -c 10.0.0.2 (or any client)
+ * ... and gbos's own irc(1) can join the same channel from another GB.
+ *
+ * Implements the subset a real client needs to get in and talk: NICK, USER,
+ * PING/PONG, JOIN, PART, PRIVMSG/NOTICE (to a channel or a nick), NAMES,
+ * QUIT, plus the 001/004/375/372/376 numerics clients wait for on registration
+ * and 353/366 on join. One channel per client keeps the bookkeeping to a
+ * fixed-size table - this is a 1997 handheld with 8 KiB of task RAM.
+ *
+ * MAXCL is bounded by the kernel socket table (MAX_SOCKS), which the listener
+ * and every client each take one of; the rest are left for other programs.
+ *
+ * Statics are fine here because main() copies the command line off $A000
+ * before touching any of them - sock.h's _nr already lives there.
+ */
+
+#define PORT_DEF 6667
+#define MAXCL 3 /* MAX_SOCKS(6) - listener - 2 spare */
+#define INBUF 128 /* longest line we will reassemble */
+#define NICKSZ 10
+#define CHANSZ 12
+#define OBUFSZ 200 /* one segment */
+#define RBUFSZ 200
+
+static unsigned char lsock;
+static unsigned char csock[MAXCL];
+static unsigned char cused[MAXCL];
+static unsigned char creg[MAXCL]; /* sent the welcome burst */
+static unsigned char cgotu[MAXCL]; /* seen USER */
+static char cnick[MAXCL][NICKSZ];
+static char cchan[MAXCL][CHANSZ];
+static char cin[MAXCL][INBUF];
+static unsigned char cinlen[MAXCL];
+static unsigned char rbuf[RBUFSZ];
+static char msg[OBUFSZ];
+static unsigned char mlen;
+static char line[INBUF]; /* the line being dispatched */
+
+/* ---- tiny string helpers -------------------------------------------------- */
+static unsigned char seq(const char *a, const char *b) {
+ while (*a && *a == *b) {
+ a++;
+ b++;
+ }
+ return (unsigned char)(*a == *b);
+}
+static unsigned char up(char c) { return (unsigned char)((c >= 'a' && c <= 'z') ? c - 32 : c); }
+/* case-insensitive compare of a command word */
+static unsigned char cmdis(const char *w, const char *name) {
+ while (*name) {
+ if (up(*w) != (unsigned char)*name) return 0;
+ w++;
+ name++;
+ }
+ return (unsigned char)(*w == 0);
+}
+static void scpy(char *d, const char *s, unsigned char max) {
+ unsigned char i = 0;
+ while (s[i] && i < max - 1) {
+ d[i] = s[i];
+ i++;
+ }
+ d[i] = 0;
+}
+
+/* ---- outgoing message builder --------------------------------------------- */
+static void m(const char *s) {
+ while (*s && mlen < OBUFSZ - 2) msg[mlen++] = *s++;
+}
+static void mstart(void) { mlen = 0; }
+static void mend(void) {
+ msg[mlen++] = '\r';
+ msg[mlen++] = '\n';
+}
+static void msend(unsigned char ci) {
+ if (cused[ci]) net_send(csock[ci], msg, mlen);
+}
+/* ":nick!u@gbos " - the prefix a client needs to attribute an event */
+static void mprefix(unsigned char ci) {
+ mstart();
+ m(":");
+ m(cnick[ci]);
+ m("!u@gbos ");
+}
+/* ":gbos NNN nick " - a numeric reply */
+static void mnumeric(unsigned char ci, const char *num) {
+ mstart();
+ m(":gbos ");
+ m(num);
+ m(" ");
+ m(cnick[ci][0] ? cnick[ci] : "*");
+ m(" ");
+}
+/* everyone in ci's channel (except `except`, use 0xFF for nobody) */
+static void bcast(const char *chan, unsigned char except) {
+ unsigned char i;
+ if (!chan[0]) return;
+ for (i = 0; i < MAXCL; i++)
+ if (cused[i] && creg[i] && i != except && seq(cchan[i], chan)) msend(i);
+}
+
+static void logline(const char *a, const char *b) {
+ puts(a);
+ if (b) puts(b);
+ nl();
+}
+
+/* ---- client lifecycle ------------------------------------------------------ */
+static void drop(unsigned char ci, const char *why) {
+ if (!cused[ci]) return;
+ if (creg[ci] && cchan[ci][0]) { /* tell the channel it lost them */
+ mprefix(ci);
+ m("QUIT :");
+ m(why);
+ mend();
+ bcast(cchan[ci], ci);
+ }
+ logline("- ", cnick[ci][0] ? cnick[ci] : "(unregistered)");
+ net_close(csock[ci]);
+ cused[ci] = 0;
+ creg[ci] = 0;
+ cgotu[ci] = 0;
+ cinlen[ci] = 0;
+ cnick[ci][0] = 0;
+ cchan[ci][0] = 0;
+}
+
+static void welcome(unsigned char ci) {
+ creg[ci] = 1;
+ mnumeric(ci, "001");
+ m(":Welcome to gbos IRC, ");
+ m(cnick[ci]);
+ mend();
+ msend(ci);
+ mnumeric(ci, "004");
+ m("gbos gbos-1.0 o o");
+ mend();
+ msend(ci);
+ mnumeric(ci, "375");
+ m(":- gbos Message of the Day -");
+ mend();
+ msend(ci);
+ mnumeric(ci, "372");
+ m(":- an IRC server on a Game Boy Color");
+ mend();
+ msend(ci);
+ mnumeric(ci, "376");
+ m(":End of /MOTD command.");
+ mend();
+ msend(ci);
+ logline("+ ", cnick[ci]);
+}
+
+/* 353 (names) + 366 (end of names) for ci's channel */
+static void names(unsigned char ci) {
+ unsigned char i;
+ mnumeric(ci, "353");
+ m("= ");
+ m(cchan[ci]);
+ m(" :");
+ for (i = 0; i < MAXCL; i++)
+ if (cused[i] && creg[i] && seq(cchan[i], cchan[ci])) {
+ m(cnick[i]);
+ m(" ");
+ }
+ mend();
+ msend(ci);
+ mnumeric(ci, "366");
+ m(cchan[ci]);
+ m(" :End of /NAMES list");
+ mend();
+ msend(ci);
+}
+
+/* ---- command dispatch ------------------------------------------------------ */
+/* line[] holds one command, already stripped of CR/LF. Splits in place. */
+static void dispatch(unsigned char ci) {
+ char *cmd, *a1, *a2, *p;
+ unsigned char i;
+
+ p = line;
+ if (*p == ':') { /* ignore any client-sent prefix */
+ while (*p && *p != ' ') p++;
+ while (*p == ' ') p++;
+ }
+ cmd = p;
+ while (*p && *p != ' ') p++;
+ if (*p) {
+ *p++ = 0;
+ while (*p == ' ') p++;
+ }
+ a1 = p; /* first argument (may be empty) */
+ while (*p && *p != ' ') p++;
+ if (*p) {
+ *p++ = 0;
+ while (*p == ' ') p++;
+ }
+ a2 = p; /* trailing text (may start ':') */
+ if (*a2 == ':') a2++;
+
+ if (cmdis(cmd, "NICK")) {
+ if (!a1[0]) return;
+ scpy(cnick[ci], a1, NICKSZ);
+ if (cgotu[ci] && !creg[ci]) welcome(ci);
+ return;
+ }
+ if (cmdis(cmd, "USER")) {
+ cgotu[ci] = 1;
+ if (cnick[ci][0] && !creg[ci]) welcome(ci);
+ return;
+ }
+ if (cmdis(cmd, "PING")) {
+ mstart();
+ m(":gbos PONG gbos :");
+ m(a1[0] ? a1 : "gbos");
+ mend();
+ msend(ci);
+ return;
+ }
+ if (cmdis(cmd, "QUIT")) {
+ drop(ci, a2[0] ? a2 : "Client quit");
+ return;
+ }
+ if (!creg[ci]) return; /* everything below needs a nick */
+
+ if (cmdis(cmd, "JOIN")) {
+ if (!a1[0]) return;
+ if (cchan[ci][0]) { /* one channel at a time */
+ mprefix(ci);
+ m("PART ");
+ m(cchan[ci]);
+ mend();
+ bcast(cchan[ci], 0xFF);
+ }
+ scpy(cchan[ci], a1, CHANSZ);
+ mprefix(ci);
+ m("JOIN ");
+ m(cchan[ci]);
+ mend();
+ msend(ci); /* the joiner sees it too */
+ bcast(cchan[ci], ci);
+ names(ci);
+ logline(cnick[ci], " joined");
+ return;
+ }
+ if (cmdis(cmd, "PART")) {
+ if (!cchan[ci][0]) return;
+ mprefix(ci);
+ m("PART ");
+ m(cchan[ci]);
+ mend();
+ msend(ci);
+ bcast(cchan[ci], ci);
+ cchan[ci][0] = 0;
+ return;
+ }
+ if (cmdis(cmd, "NAMES")) {
+ if (cchan[ci][0]) names(ci);
+ return;
+ }
+ if (cmdis(cmd, "PRIVMSG") || cmdis(cmd, "NOTICE")) {
+ if (!a1[0]) return;
+ mprefix(ci);
+ m(cmdis(cmd, "NOTICE") ? "NOTICE " : "PRIVMSG ");
+ m(a1);
+ m(" :");
+ m(a2);
+ mend();
+ if (a1[0] == '#') {
+ bcast(a1, ci); /* to a channel, not back to us */
+ puts("<");
+ puts(cnick[ci]);
+ puts("> ");
+ puts(a2);
+ nl();
+ } else {
+ for (i = 0; i < MAXCL; i++) /* to one nick */
+ if (cused[i] && creg[i] && seq(cnick[i], a1)) msend(i);
+ }
+ return;
+ }
+ mnumeric(ci, "421");
+ m(cmd);
+ m(" :Unknown command");
+ mend();
+ msend(ci);
+}
+
+/* feed received bytes into ci's line buffer, dispatching each complete line */
+static void feed(unsigned char ci, unsigned char n) {
+ unsigned char i;
+ char c;
+ for (i = 0; i < n; i++) {
+ c = (char)rbuf[i];
+ if (c == '\n' || c == '\r') {
+ if (cinlen[ci]) {
+ scpy(line, cin[ci], INBUF);
+ cinlen[ci] = 0;
+ cin[ci][0] = 0;
+ dispatch(ci);
+ if (!cused[ci]) return; /* QUIT closed us mid-buffer */
+ }
+ continue;
+ }
+ if (cinlen[ci] < INBUF - 1) {
+ cin[ci][cinlen[ci]++] = c;
+ cin[ci][cinlen[ci]] = 0;
+ }
+ }
+}
+
+static unsigned int atou16(const char *s) {
+ unsigned int n = 0;
+ while (*s >= '0' && *s <= '9') {
+ n = n * 10 + (unsigned char)(*s - '0');
+ s++;
+ }
+ return n;
+}
+
+void main(void) {
+ char args[16];
+ unsigned int port;
+ unsigned char i, s, n, idle;
+
+ { /* copy argv before any static (incl. _nr) lands on $A000 */
+ char *a = getargs();
+ for (i = 0; a[i] && i < 15; i++) args[i] = a[i];
+ args[i] = 0;
+ }
+ for (i = 0; i < MAXCL; i++) { /* gbos does not zero statics */
+ cused[i] = 0;
+ creg[i] = 0;
+ cgotu[i] = 0;
+ cinlen[i] = 0;
+ cnick[i][0] = 0;
+ cchan[i][0] = 0;
+ cin[i][0] = 0;
+ }
+ mlen = 0;
+ port = (args[0] >= '0' && args[0] <= '9') ? atou16(args) : PORT_DEF;
+
+ lsock = net_socket(SOCK_TCP);
+ if (lsock == 0xFF) {
+ puts("ircd: no socket");
+ nl();
+ sexit(1);
+ }
+ if (net_listen(lsock, port) == 0xFF) {
+ puts("ircd: listen failed (port in use?)");
+ nl();
+ net_close(lsock);
+ sexit(1);
+ }
+ puts("ircd: listening on port ");
+ putu(port);
+ puts(" (max ");
+ putu(MAXCL);
+ puts(" clients)");
+ nl();
+
+ idle = 0;
+ for (;;) {
+ s = net_accept(lsock); /* one RX pump per call */
+ if (s != 0xFE) {
+ for (i = 0; i < MAXCL && cused[i]; i++);
+ if (i == MAXCL) {
+ net_close(s); /* full: let them try later */
+ logline("! refused (full)", 0);
+ } else {
+ csock[i] = s;
+ cused[i] = 1;
+ creg[i] = 0;
+ cgotu[i] = 0;
+ cinlen[i] = 0;
+ cnick[i][0] = 0;
+ cchan[i][0] = 0;
+ cin[i][0] = 0;
+ logline("* client connected", 0);
+ }
+ idle = 0;
+ }
+ for (i = 0; i < MAXCL; i++) {
+ if (!cused[i]) continue;
+ n = net_recv_nb(csock[i], rbuf, RBUFSZ);
+ if (n == 0) {
+ drop(i, "Connection closed");
+ continue;
+ }
+ if (n == 0xFE || n == 0xFF) continue;
+ feed(i, n);
+ idle = 0;
+ }
+ if (++idle >= 4) {
+ msleep(20);
+ idle = 4;
+ }
+ }
+}