aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-24 22:45:04 +0200
committeruser <user@clank>2026-09-24 22:45:04 +0200
commit4cd933009dd6ce87fe710acfca6b1f786d51040c (patch)
tree67e50c2ab347ddc2ac3e5b6a5cf5a4cfc44cc8d0
parentfs: mount the builtins at /bin (diff)
downloadgbos-4cd933009dd6ce87fe710acfca6b1f786d51040c.tar.gz
gbos-4cd933009dd6ce87fe710acfca6b1f786d51040c.tar.xz
gbos-4cd933009dd6ce87fe710acfca6b1f786d51040c.zip
usr: touch, tr, rev, cut, uniq
/# 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.
-rw-r--r--Makefile3
-rw-r--r--README.md5
-rw-r--r--include/gbos.inc5
-rw-r--r--src/programs.asm35
-rw-r--r--usr/cut.c89
-rw-r--r--usr/rev.c43
-rw-r--r--usr/touch.c26
-rw-r--r--usr/tr.c87
-rw-r--r--usr/uniq.c84
9 files changed, 374 insertions, 3 deletions
diff --git a/Makefile b/Makefile
index b3f1b0e..3f07ec2 100644
--- a/Makefile
+++ b/Makefile
@@ -29,7 +29,8 @@ 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 ircd \
- df mv sleep tee tail help poweroff xxd
+ df mv sleep tee tail help poweroff xxd \
+ touch tr rev cut uniq
CBLOBS := $(patsubst %,$(BUILD)/usr/%.bin,$(PROGS))
$(BUILD)/programs.o: $(CBLOBS)
diff --git a/README.md b/README.md
index e828915..d0c3e8b 100644
--- a/README.md
+++ b/README.md
@@ -51,9 +51,10 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).*
show up as one anyway — `ls /bin` enumerates the kernel's own command
table, `cd /bin` works, and `xxd -l 16 /bin/uname` dumps the program's ROM
bank. Read-only, since it is ROM.
-- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~45
+- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~50
tools ship in `usr/` (`cat`, `wc`, `head`, `tail`, `grep`, `cp`, `mv`,
- `tee`, `more`, `ls`, `df`, `xxd`, `sleep`, `uptime`, …) — `help` lists them all on
+ `tee`, `tr`, `cut`, `uniq`, `rev`, `more`, `ls`, `df`, `xxd`,
+ `touch`, `sleep`, `uptime`, …) — `help` lists them all on
the Game Boy itself. Adding a program is one `.c` file + a table entry.
## Quick start
diff --git a/include/gbos.inc b/include/gbos.inc
index 32418b6..a51dd31 100644
--- a/include/gbos.inc
+++ b/include/gbos.inc
@@ -338,5 +338,10 @@ DEF PROG_TAIL EQU 43
DEF PROG_HELP EQU 44
DEF PROG_POWEROFF EQU 45
DEF PROG_XXD EQU 46
+DEF PROG_TOUCH EQU 47
+DEF PROG_TR EQU 48
+DEF PROG_REV EQU 49
+DEF PROG_CUT EQU 50
+DEF PROG_UNIQ EQU 51
ENDC
diff --git a/src/programs.asm b/src/programs.asm
index a602350..e9e1be9 100644
--- a/src/programs.asm
+++ b/src/programs.asm
@@ -181,6 +181,21 @@ ProgPoweroff:
SECTION "prog_xxd", ROMX[$4000], BANK[48]
ProgXxd:
INCBIN "build/usr/xxd.bin"
+SECTION "prog_touch", ROMX[$4000], BANK[49]
+ProgTouch:
+ INCBIN "build/usr/touch.bin"
+SECTION "prog_tr", ROMX[$4000], BANK[50]
+ProgTr:
+ INCBIN "build/usr/tr.bin"
+SECTION "prog_rev", ROMX[$4000], BANK[51]
+ProgRev:
+ INCBIN "build/usr/rev.bin"
+SECTION "prog_cut", ROMX[$4000], BANK[52]
+ProgCut:
+ INCBIN "build/usr/cut.bin"
+SECTION "prog_uniq", ROMX[$4000], BANK[53]
+ProgUniq:
+ INCBIN "build/usr/uniq.bin"
; -----------------------------------------------------------------------------
; PROG_SH (bank 3) - the shell, now written in C (usr/sh.c): parses >/</| and
@@ -317,6 +332,16 @@ ProgramTable::
dw ProgPoweroff
db LOW(BANK(ProgXxd)), HIGH(BANK(ProgXxd))
dw ProgXxd
+ db LOW(BANK(ProgTouch)), HIGH(BANK(ProgTouch))
+ dw ProgTouch
+ db LOW(BANK(ProgTr)), HIGH(BANK(ProgTr))
+ dw ProgTr
+ db LOW(BANK(ProgRev)), HIGH(BANK(ProgRev))
+ dw ProgRev
+ db LOW(BANK(ProgCut)), HIGH(BANK(ProgCut))
+ dw ProgCut
+ db LOW(BANK(ProgUniq)), HIGH(BANK(ProgUniq))
+ dw ProgUniq
ProgramTableEnd::
; -----------------------------------------------------------------------------
@@ -418,4 +443,14 @@ NameTable::
db PROG_POWEROFF
db "xxd", 0
db PROG_XXD
+ db "touch", 0
+ db PROG_TOUCH
+ db "tr", 0
+ db PROG_TR
+ db "rev", 0
+ db PROG_REV
+ db "cut", 0
+ db PROG_CUT
+ db "uniq", 0
+ db PROG_UNIQ
db 0
diff --git a/usr/cut.c b/usr/cut.c
new file mode 100644
index 0000000..18de461
--- /dev/null
+++ b/usr/cut.c
@@ -0,0 +1,89 @@
+#include "gbos.h"
+/* cut -f N [-d C] [file]: print field N of each line.
+ *
+ * The delimiter defaults to TAB, like cut(1); -d takes the first character of
+ * its argument, so `-d :` works - and since the shell has no quoting, `-d \\s`
+ * is how you say space (\\t tab, \\n newline). A line with no delimiter is passed through
+ * whole (what GNU cut does without -s), which is usually what you want when
+ * filtering mixed output on a 40-column screen. */
+
+#define MAXLINE 80
+
+/* The shell has no quoting, so a space or a tab cannot be typed as an
+ argument: "-d ' '" arrives as three tokens. Escapes are how you name them
+ here - \s space, \t tab, \n newline, \\ backslash. */
+static char unesc1(const char *s, unsigned char *used) {
+ *used = 1;
+ if (s[0] != '\\' || !s[1]) return s[0];
+ *used = 2;
+ switch (s[1]) {
+ case 's': return ' ';
+ case 't': return '\t';
+ case 'n': return '\n';
+ case 'r': return '\r';
+ default: return s[1];
+ }
+}
+
+void main(void) {
+ char *argv[8];
+ char line[MAXLINE];
+ unsigned char argc = argv_parse(argv, 8);
+ char *fv = optval(argv, argc, 'f');
+ char *dv = optval(argv, argc, 'd');
+ unsigned char dused;
+ char delim = dv ? unesc1(dv, &dused) : '\t';
+ unsigned char want = fv ? atou(fv) : 1;
+ unsigned char len = 0, fd = NOFD, i, field, start, end;
+ char *fname = 0;
+ int ch;
+
+ if (want == 0) want = 1;
+ for (i = 0; i < argc; i++) {
+ if (argv[i][0] == '-') continue;
+ if (fv == argv[i] || dv == argv[i]) continue;
+ fname = argv[i];
+ break;
+ }
+ if (fname) {
+ fd = open(fname, O_READ);
+ if (fd == EISDIR) {
+ puts("cut: is a directory");
+ nl();
+ sexit(2);
+ }
+ if (fd == NOFD) {
+ puts("cut: no such file");
+ nl();
+ sexit(2);
+ }
+ }
+ for (;;) {
+ if (fd == NOFD) {
+ char c = readc();
+ ch = (c == EOF) ? -1 : c;
+ } else ch = fgetc(fd);
+ if (ch == '\r') continue;
+ if (ch >= 0 && ch != '\n') {
+ if (len < MAXLINE) line[len++] = (char)ch;
+ continue;
+ }
+ if (ch < 0 && len == 0) break;
+ /* walk to the wanted field */
+ field = 1;
+ start = 0;
+ for (i = 0; i <= len; i++) {
+ if (i == len || line[i] == delim) {
+ if (field == want) break;
+ field++;
+ start = (unsigned char)(i + 1);
+ }
+ }
+ end = i;
+ if (field == want && start <= len) writes(line + start, (unsigned char)(end - start));
+ nl();
+ len = 0;
+ if (ch < 0) break;
+ }
+ if (fd != NOFD) close(fd);
+}
diff --git a/usr/rev.c b/usr/rev.c
new file mode 100644
index 0000000..2932190
--- /dev/null
+++ b/usr/rev.c
@@ -0,0 +1,43 @@
+#include "gbos.h"
+/* rev [file]: reverse the characters of each line, from a file or stdin. */
+
+#define MAXLINE 80
+
+void main(void) {
+ char *argv[4];
+ char line[MAXLINE];
+ unsigned char argc = argv_parse(argv, 4);
+ unsigned char len = 0, fd = NOFD, i;
+ int ch;
+
+ if (argc > 0 && argv[0][0] != '-') {
+ fd = open(argv[0], O_READ);
+ if (fd == EISDIR) {
+ puts("rev: is a directory");
+ nl();
+ sexit(2);
+ }
+ if (fd == NOFD) {
+ puts("rev: no such file");
+ nl();
+ sexit(2);
+ }
+ }
+ for (;;) {
+ if (fd == NOFD) {
+ char c = readc();
+ ch = (c == EOF) ? -1 : c;
+ } else ch = fgetc(fd);
+ if (ch == '\r') continue;
+ if (ch >= 0 && ch != '\n') {
+ if (len < MAXLINE) line[len++] = (char)ch;
+ continue;
+ }
+ if (ch < 0 && len == 0) break;
+ for (i = len; i--;) putc(line[i]);
+ nl();
+ len = 0;
+ if (ch < 0) break;
+ }
+ if (fd != NOFD) close(fd);
+}
diff --git a/usr/touch.c b/usr/touch.c
new file mode 100644
index 0000000..4f5ba89
--- /dev/null
+++ b/usr/touch.c
@@ -0,0 +1,26 @@
+#include "gbos.h"
+/* touch FILE...: create FILE if it does not exist, leave it alone if it does.
+ *
+ * There are no timestamps in gbfs (no RTC on an MBC5 cart), so touch only does
+ * the half that means anything here: make an empty file. O_APPEND is exactly
+ * right for it - it creates, and it does NOT truncate what is already there. */
+void main(void) {
+ char *argv[8];
+ unsigned char argc = argv_parse(argv, 8);
+ unsigned char i, fd;
+ if (argc < 1) {
+ puts("usage: touch FILE...");
+ nl();
+ sexit(2);
+ }
+ for (i = 0; i < argc; i++) {
+ fd = open(argv[i], O_APPEND);
+ if (fd > 3) {
+ puts("touch: cannot create ");
+ puts(argv[i]);
+ nl();
+ continue;
+ }
+ close(fd);
+ }
+}
diff --git a/usr/tr.c b/usr/tr.c
new file mode 100644
index 0000000..a98cf1d
--- /dev/null
+++ b/usr/tr.c
@@ -0,0 +1,87 @@
+#include "gbos.h"
+/* tr [-d] SET1 [SET2]: translate or delete characters, stdin to stdout.
+ *
+ * ls | tr a-z A-Z ranges work
+ * cat f | tr -d aeiou -d deletes instead of mapping
+ * echo a b | tr \\s \\n \\s \\t \\n name characters the shell cannot quote
+ *
+ * ASCII only (a 128-entry map on the stack), and like tr(1) a short SET2 is
+ * padded with its last character. */
+
+#define SETMAX 64
+
+/* The shell has no quoting, so a space or a tab cannot be typed as an
+ argument: "-d ' '" arrives as three tokens. Escapes are how you name them
+ here - \s space, \t tab, \n newline, \\ backslash. */
+static char unesc1(const char *s, unsigned char *used) {
+ *used = 1;
+ if (s[0] != '\\' || !s[1]) return s[0];
+ *used = 2;
+ switch (s[1]) {
+ case 's': return ' ';
+ case 't': return '\t';
+ case 'n': return '\n';
+ case 'r': return '\r';
+ default: return s[1];
+ }
+}
+
+/* expand "a-z0-9_" into the characters it names; returns how many */
+static unsigned char expand(const char *s, char *out, unsigned char max) {
+ unsigned char n = 0, i = 0;
+ char a, b;
+ while (s[i] && n < max) {
+ unsigned char ua, ub;
+ a = unesc1(s + i, &ua);
+ if (s[i + ua] == '-' && s[i + ua + 1]) { /* a range */
+ b = unesc1(s + i + ua + 1, &ub);
+ while (a <= b && n < max) out[n++] = a++;
+ i += ua + 1 + ub;
+ } else {
+ out[n++] = a;
+ i += ua;
+ }
+ }
+ return n;
+}
+
+void main(void) {
+ char *argv[8];
+ char s1[SETMAX], s2[SETMAX];
+ unsigned char map[128];
+ unsigned char argc = argv_parse(argv, 8);
+ unsigned char del = hasflag(argv, argc, 'd');
+ unsigned char i, n1 = 0, n2 = 0, k;
+ char *set1 = 0, *set2 = 0;
+ char c;
+
+ for (i = 0; i < argc; i++) {
+ /* a bare "-" is a SET, not a flag: `tr . -` maps dots to dashes */
+ if (argv[i][0] == '-' && argv[i][1]) continue;
+ if (!set1) set1 = argv[i];
+ else if (!set2) set2 = argv[i];
+ }
+ if (!set1 || (!del && !set2)) {
+ puts("usage: tr [-d] SET1 [SET2]");
+ nl();
+ sexit(2);
+ }
+ n1 = expand(set1, s1, SETMAX);
+ if (set2) n2 = expand(set2, s2, SETMAX);
+
+ for (i = 0; i < 128; i++) map[i] = i; /* identity, then patch */
+ for (k = 0; k < n1; k++) {
+ unsigned char from = (unsigned char)s1[k] & 127;
+ if (del) map[from] = 0; /* 0 = drop it */
+ else map[from] = (unsigned char)s2[k < n2 ? k : n2 - 1];
+ }
+
+ for (;;) {
+ c = readc();
+ if (c == EOF) break;
+ if ((unsigned char)c < 128) {
+ unsigned char o = map[(unsigned char)c];
+ if (o) putc((char)o);
+ } else putc(c);
+ }
+}
diff --git a/usr/uniq.c b/usr/uniq.c
new file mode 100644
index 0000000..2c6ebdc
--- /dev/null
+++ b/usr/uniq.c
@@ -0,0 +1,84 @@
+#include "gbos.h"
+/* uniq [-c] [file]: collapse runs of identical ADJACENT lines (like uniq(1) -
+ * it does not sort). -c prefixes each line with how many times it repeated. */
+
+#define MAXLINE 80
+
+static unsigned char lines_eq(const char *a, unsigned char la, const char *b, unsigned char lb) {
+ unsigned char i;
+ if (la != lb) return 0;
+ for (i = 0; i < la; i++)
+ if (a[i] != b[i]) return 0;
+ return 1;
+}
+
+void main(void) {
+ char *argv[4];
+ char cur[MAXLINE], prev[MAXLINE];
+ unsigned char argc = argv_parse(argv, 4);
+ unsigned char cnt = hasflag(argv, argc, 'c');
+ unsigned char clen = 0, plen = 0, have = 0, fd = NOFD, i;
+ unsigned int reps = 0;
+ char *fname = 0;
+ int ch;
+
+ for (i = 0; i < argc; i++)
+ if (argv[i][0] != '-') {
+ fname = argv[i];
+ break;
+ }
+ if (fname) {
+ fd = open(fname, O_READ);
+ if (fd == EISDIR) {
+ puts("uniq: is a directory");
+ nl();
+ sexit(2);
+ }
+ if (fd == NOFD) {
+ puts("uniq: no such file");
+ nl();
+ sexit(2);
+ }
+ }
+ for (;;) {
+ if (fd == NOFD) {
+ char c = readc();
+ ch = (c == EOF) ? -1 : c;
+ } else ch = fgetc(fd);
+ if (ch == '\r') continue;
+ if (ch >= 0 && ch != '\n') {
+ if (clen < MAXLINE) cur[clen++] = (char)ch;
+ continue;
+ }
+ /* EOF with nothing buffered: stop. Falling through would compare an
+ empty "line" against the last run and flush a phantom blank one -
+ which showed up as a stray "1" in uniq -c. */
+ if (ch < 0 && clen == 0) break;
+ if (have && lines_eq(cur, clen, prev, plen)) reps++;
+ else {
+ if (have) { /* flush the run that just ended */
+ if (cnt) {
+ putu(reps);
+ putc(' ');
+ }
+ writes(prev, plen);
+ nl();
+ }
+ for (i = 0; i < clen; i++) prev[i] = cur[i];
+ plen = clen;
+ have = 1;
+ reps = 1;
+ }
+ clen = 0;
+ if (ch < 0) break;
+ }
+ if (have) {
+ if (cnt) {
+ putu(reps);
+ putc(' ');
+ }
+ writes(prev, plen);
+ nl();
+ }
+ if (fd != NOFD) close(fd);
+}