VideoBeast: what do we know?
A VideoBeast release seems imminent: we round up all the currently known information.
A VideoBeast release seems imminent: we round up all the currently known information.

Phase 4 taught our Z80 Forth to address half a megabyte through one banked window. That was the big, rocky climb. Epic 23 is the opposite: a single, focused epic that sands the edges of the wordset, closes a handful of ANS conformance gaps, and adds the small ergonomic words you reach for every day.
It ships as antforth v3.1.0 — the first minor release since v3.0, and the close of Phase 5.
Five feature stories:
VALUE / TO). You can now define a mutable named cell and assign to it by name — 42 VALUE LIVES, then LIVES . prints 42, and 99 TO LIVES updates it. This is the standard ergonomic middle ground between a CONSTANT (immutable) and a VARIABLE (you fetch and store through an address). It was the biggest single piece of the epic, because making it bank-aware meant a full resolve/map/unmap trio so a VALUE defined in any bank reads and writes correctly.IN / OUT). Direct Z80 port reads and writes, surfaced as Forth words for talking to hardware.UD. and ENVIRONMENT? rows). UD. prints an unsigned double. More interestingly, the new ENVIRONMENT? rows tell the truth: queries for wordsets antforth only partially implements answer false ("recognised but incomplete") rather than a flattering true. 23.6). More on this below.The most valuable thing Epic 23 produced is a bug that didn't reach silicon.
antforth's code review runs adversarially, in a fresh context. The standing rule is that a review which finds nothing is suspect, not reassuring. On the named-values and port-words stories, that review surfaced a silent correctness defect: a banked colon definition whose body grew past $C000 would have its runtime read through the wrong bank window. No crash, just quietly wrong results, the worst kind of bug.
That finding became its own story (23.6): a window-top overflow guard. Its own review then caught a second one: a 16-bit wraparound in ALLOT. Both would have shipped silently. Neither did.
Why this matters. The temptation in a "polish" epic is to relax the adversary. The work feels low-risk, the reviews feel like ceremony. Epic 23 is the counter-argument: the one story that mattered most for correctness existed only because the review refused to relax.
Every byte is measured from a clean rebuild and accepted at its story's close. The epic grew the kernel by +563 bytes, and free RAM measured on real hardware (24,954 B) independently corroborates that ledger.
| Story | What | Δ bytes |
|---|---|---|
| 23.1 | Assembler operand-order fix (Zilog dst-src) | 0 |
| 23.2 | VALUE / TO (bank-aware) |
+317 |
| 23.3 | IN / OUT port words |
+19 |
| 23.4 | UD. + honest ENVIRONMENT? rows |
+112 |
| 23.6 | Banked window-top overflow guard | +115 |
| antforth v3.1.0 total | +563 |
The original estimate was ~300 B, so the epic ran about 1.9× over, squarely inside the ~2.4× "cross-bank plumbing always undershoots" multiplier we learned to budget for in Phase 4. The single biggest overrun (VALUE/TO) is exactly where the bank-aware machinery lives. The pattern keeps confirming itself.
v3.1.0 is a tidier, more conformant Forth than v3.0.7: named mutable values, hardware port access, and environment queries that don't oversell themselves. All of it bank-aware, all of it verified on real hardware.
The big platform shifts (a cooperative multitasker, counting semaphores, full ANS locals) are recorded for a future phase. Epic 23 did quieter, equally necessary work: making the foundation strong before further building on top of it.
A focused standards-and-I/O round that took antforth to v3.1.0 : named values, port words, honest environment queries, and a banking bug the review refused to let ship.
Recently there was an innocuous comment on the MicroBeast discord from our Great Leader: which led me down a bit of a rabbit-hole. Why's it hard? It's hard because of a well-documented z80 shortcoming: if an LD A,I or LD A,R is itself interrupted,
In part 3 we implemented an assembler version of our traffic light code, just for kicks. In the same spirit I'm going to take you out of your comfort zone and show you an implementation in a very different language.... Forth. Don't panic! your deep intuition
In part 2 we wrote a simple BBC BASIC program to drive our traffic light model. In this part we'll do the same thing in z80 assembler. There's no compelling reason to do this apart from the fun of writing z80 assembler - it's
In Part One we focused on the hardware for our traffic light project: this time around, we're going to be looking at the software. I'll start with a simple BASIC version, and then we'll look at some other options in later posts. BBC BASIC
The MicroBeast and NanoBeast are extremely expandable computers - they offer a range of interfaces including GPIO, I2C, UART, and RC2014. These interfaces vary in their complexity and capabilities, ranging from turning a simple LED on and off all the way up to a full-blown graphics card.
We'll be taking a look at the PIO interface, and using it to control 3 LEDs arranged in the standard traffic light pattern of red, amber, and green. We'll write some code to turn the LEDs on and off, and then we'll concentrate on sequencing them so they behave like a real traffic light.
The interface I've chosen for this project is the 'Beast's GPIO (General Purpose Input/Output). This is a digital interface: we can either read a signal to see if it's HIGH (5 volts) or LOW (0 volts), or we can turn things around and drive a signal either HIGH or LOW, to control something in the outside world (like an LED).
The 'Beast's GPIO interface is split into two halves called "Port A" and "Port B". Each of these has 8 "lines" (or "pins", or "signals") that we can use for whatever we want...well almost: the 'Beast is already using some of the Port B pins itself to interface with the I2C bus, the real time clock, and the UART. To keep things simple, we'll restrict ourselves to using Port A.
The Z80 CPU launched with a companion chip, the Z80 PIO:
The Z8O Parallel I/O (PlO) Circuit is a programmable, two port device which provides a TTL compatible interface between peripheral devices and the Z80-GPU. The CPU can configure the Z8O-PIO to interface with a wide range of peripheral devices with no other external logic required, Typical peripheral devices that are fully compatible with the Z80-PIO include most keyboards, paper tape readers and punches, printers, PROM programmers, etc.
This integrates so well with the Z80 (particularly in the arena of interrupts) that it is still a popular choice with system designers. The MicroBeast includes a Z84C20, the modern variant. Here's the datasheet, and here's a handy user manual.
The NanoBeast has a different chip that we'll cover another time, but it is broadly compatible [1].
I think - not had a chance to play with one yet. ↩︎
We can treat the Z84C20 as a container for two ports which are (almost) identical. Each port has some registers that allow us to control it: that's a specific set of addresses in the Z80 CPU's I/O address space that allow us to control the Z84C20. These are:
Each port supports 4 different modes, they are:
We'll be using mode 3, as we want individual control of 3 different LEDs, and this mode is the easiest way to achieve it.
Let's get started on the hardware! First of all we're going to need a bit of breadboard, some LEDs and resistors, lots of precut wire links / bits of wire, and some hookup wires with 0.1" female headers for connection to the MicroBeast. We'll also need an integrated circuit, a 74HC244.
Here's an overview of what we'll be building:
First off we'll need some LEDs of the appropriate colours. I raided the parts drawer and came up with three likely candidates of dubious provenance. Measuring them with a multimeter I found the voltage drop across my red LED was 1.7v, the yellow LED was 1.8v, and the green LED was 2 volts.
We want to limit the current through these LEDs to about 10mA (plenty bright enoufg for our purposes), so we'll need a resistor for each LED. We can calculate the resistor value with the formula R = (Vcc - Vf) / If where Vf is about 2 volts (as measured), Vcc is 5V and If is 10 mA. This gives a value of 300 ohms. I don't have any 300 ohm resistors, but I do have 330 Ω, and pllugging that back in to the formula reveals that that gives an If of 9 mA, which is absolutely fine.
You might be wondering why we don't wire the LEDs directly to the 'Beast's PIO output: the Z84C20 can neither source (provide) nor sink (accept) 9mA - in fact it can only source 1.6 mA and sink 2mA, which is rather puny.
This is the reason for the 74HC244 IC in our circuit.
This is a package of 8 (hence "octal") "buffer"s, where a buffer is a device that has one input and one output, and the output's value follows the input. This might sound a bit pointless, but the buffer also isolates the input from the output, and provides a significant current boost.
In the case of 74HC2444, each pin can sink or source 35mA, with the proviso that the total current handled by the chip cannot exceed 70mA. So our 3 * 9 mA = 27 mA total is not going to cause the 74HC244 to break a sweat, even though it would give the Z84C20 a bit of a migraine.
Just to be safe, we're using the 74HC244 to sink current rather than sourcing it: this means that current flows from the power supply, through the LED and resistor, and is delivered into the 74HC244. This has the consequence of inverting the control logic (we drive a line LOW to light the LED) - more on this later.
You can buy 74HC244s from all the usual component suppliers, but they are often expensive in small quantities. eBay or AliExpress are a much cheaper option if you can wait for a few days, but beware fakes and duds. I bought 10 from AliExpress and they all checked out fine. If you're really desperate, get hold of me on Discord and I'll send you one.
Our RED led is wired to the 'Beast's PA0 pin, the amber LED to PA1, and the green PIN to PA2:
This means writing the value 0x01 to Port A's data port will control the red LED, the value 0x02 will control the amber LED, and the value 0x04 will control the green LED.
If that leaves you a bit mystified, you could try our introduction to binary and hex.
Here's my board for reference:

The rubber band is there to provide "strain relief", i.e. stop the Dupont pins pulling out of the board when you move the cables.
Don't forget to connect your power rails together (as in, connect the top + rail to the bottom + rail, and connect the top - rail to the bottom - rail) – do not connect a + rail to a - rail! In the Fritzing diagram this is shown as a long pink wire and a long black wire across the centre of the board. In the photo above, it's the two X shaped grey wires at the bottom.
Also worth noting that your LEDs, being diodes, need to go in the right way round. If you fit them in reverse, they won't work. An LEDs terminals are called the "cathode" and the "anode". Current must enter the LED at the anode and leave via the cathode. The cathode is usually marked by a slight flattening on the LED housing, and it usually has a shorted lead than the anode. If in doubt, test with a multimeter first.
I'll leave you to go have fun building the circuit. In part 2, we'll write some simple code to drive it!
The MicroBeast and NanoBeast are extremely expandable computers - they offer a range of interfaces including GPIO, I2C, UART, and RC2014. These interfaces vary in their complexity and capabilities, ranging from turning a simple LED on and off all the way up to a full-blown graphics card. We'll
If binary and hexadecimal is just so much gobbledegook to you, try this basic primer.

You've probably used a terminal emulator like Terraterm or Minicom with your 'Beast countless times, but you might be surprised to learn that it's actually designed to work with a specific terminal: the DEC VT52 circa 1975. Unlike the more well-known VT100, it's no looker. And also unlike the VT100, it is not well-supported by modern terminal emulators. The only ones I'm aware of are the one built into the Atari ST's GEM, xterm, and the open-source (Windows only) kgober/VT52.
This is a pity, because the VT52 supports a number of control codes that allow it to do exciting things like move the cursor, clear the screen, enter graphics mode and, .... well that's about it really, but you can achieve a lot with those, like:
The rudimentary game above uses VT52 control codes to move the cursor so that it can draw the bat, the ball and the score in their correct places. It's not blazingly fast, and it's a bit flickery, but it is playable!
The terminal that's being used to play that game is BeasTTY.
BeasTTY has a number of unique features:
The simplest way is to click on this link which also appears top-right on the github page.

Then click on the "Connect" button at the top, and choose your 'Beast in the resulting dialogue box:


Click on the big square in the middle (so the border goes solid) and hit ENTER, and you should be rewarded with your CP/M prompt:

Type some commands to get more output:

This is the "Amber CRT" display option with the original VT52 font. It looks its authentic best in white:

You can change the CRT colour (or switch to a modern "Clean" look) using the controls at the top of the screen:

If you want to change the font, open the "Settings" section underneath the main text window, and choose a font from the "CRT font" drop-down:

The fonts only apply in CRT mode: in "clean" mode you get a built-in monospaced font. Also note that the VT52 ROM font is the only one that supports the custom VT52 alternate graphics set (although I wouldn't get too excited, it's a pretty poor choice).
There are a couple of ways to send a file from your PC to your 'Beast using BeasTTY.
Just find the file you want to send in your file manager, and drag it onto the central region of the BeasTTY window (make sure your 'Beast is currently logged into a writable drive (i.e. B: not A:):
Notice the "chip" top right that shows you the progress: it also gives you the option to cancel the transfer.
This works because when you drag a file into the window, BeasTTY first sends the command "B:SLIDE R\r" to your 'Beast, which executes B:SLIDE.COM R which runs the SLIDE executable in receive mode.
So for this to work, you're going to need SLIDE.COM on your B drive. You can get it from the SLIDE repo.
Note that versions of the MicroBeast firmware from 1.7 onwards include the SLIDE.COM binary on the A disk as standard. In this case, you need to tell BeasTTY that you want to run the A drive version - you can do this by expanding "Settings", then expanding the "SLIDE file transfer" section, then changing the "Auto-send command" option: in this case, change it to A:SLIDE R - don't forgot the 'R' at the end ("receive" mode).

Another way to send a file is to click the "Send File" button at the top of the screen. This will give you a standard OS file picker where you can choose the file you want to send.
Sending a file from the 'Beast is initiated from the 'Beast itself. In the terminal window, type A:SLIDE S <name-of-file> or B:SLIDE S <name-of-file> if your SLIDE.COM binary is on the B drive.
The "S" after "SLIDE" means "send".
You can see in my example that the received file is automatically saved to a folder called "INCOMING" on my hard disk. This is achieved by setting the "Save received files to a folder" option and specifying a folder with "Change folder...".

If you turn off "Save received files to a folder" then received files will use the standard browser download mechanism.
SLIDE lets you send more than one file at a time!
Simply drag multiple files onto the BeasTTY window in one go. SLIDE will transfer them all individually.
It probably doesn't come as a great surprise to hear that you can select multiple files in the file picker that appears when you click on the "Send file" button:
Type SLIDE S <file1> <file2>... to send multiple files from the 'Beast to BeasTTY with SLIDE (that lone S means "Send"). You can specify as many files as you like, but remember that the whole line can only be 128 characters.
These will end up in your "Save received files to a folder" folder, as for the single file case. If you haven't set this folder, your browser will use the standard download mechanism, but it will likely prompt you first to check that it's OK to download multiple files.
You've probably used a terminal emulator like Terraterm or Minicom with your 'Beast countless times, but you might be surprised to learn that it's actually designed to work with a specific terminal: the DEC VT52 circa 1975. Unlike the more well-known VT100, it's

Last time in Your name in lights! (Part 5), we get a basic scrolltext working and were able to scroll any message of our choosing.
We're going to be building on that foundation here, so if you skipped earlier parts or are still a little shaky on the fundamentals you might want to go back and read them again.
To move forward, I'm going to assume that you've got SLIDE.COM on the B: drive of your 'Beast, and that you have the corresponding PC utility installed somewhere in your PATH on your development PC. Refer to the SLIDE README if you need more help with that.
I'm also going to assume that you have BBCBASIC.COM on your B drive.
First things first: clone the Beast User repository on to your development PC.
We're going to start in the scrolltext/bbc_basic/effects folder.
This time, we're going to build on our scrolltext program from before, but add a little bling.
In the first installment of this series I pointed you at the LED driver datasheet and just on the off-chance that you didn't pore over this document at the time, let me quote a bit of it here:

What this is telling us is that we can change the brightness of each character on our LED display, and that each character can be set to 256 different brightness levels!
This means we can do some cool fading effects by varying the brightness. In fact you might have noticed something similar when the 'Beast first boots. The fact that the firmware is already doing this might lead you to hope that perhaps there's a BIOS routine we can call to do the heavy lifting for us, and your faith is rewarded:

I've left the familiar MBB_WRITE_LED code on there for reference. This time we're interested in MBB_LED_BRIGHTNESS, and we can see that the address this resolves to (0FDD3h) is different from MBB_WRITE_LED (&FDD6), and moreover this time the column is passed in the A register as before, but this time brightness is passed in the C register. (Note that the comment in the firmware header is wrong; there are actually 256 brightness levels, not 128.)
The rough plan is, we'll send lovely shimmering waves of varying brightness along our text string as it's scrolling. In order to pull this off, we'll construct a lookup table where we've pre-computed brightness levels so that they follow a sine wave pattern, like this:

We could generate this lookup table in BASIC using the SIN() function, but it's a little awkward and frankly a lot easier to generate the values and cast them to integer values in the correct range using a python script on a modern PC, so that's exactly what I did.
Here's the code we'll be running this time:
10 REM === MicroBeast LED Demo - Step 5: Sine Wave Brightness (BBC BASIC) ===
20 REM Scrolling text with a sine-wave brightness effect. The brightness
30 REM wave scrolls independently of (and faster than) the text.
40 REM
50 REM Two BIOS routines are used. BBC BASIC loads the Z80 registers
60 REM from the static integer variables A%-L% before a CALL, so each
70 REM is just a couple of lines - no machine-code stubs required:
80 REM MBB_WRITE_LED (&FDD6): HL = bitmask, A = column
90 REM MBB_LED_BRIGHTNESS (&FDD3): A = column, C = brightness
100 REM
110 REM --- Load font data into an array (ASCII 32-126) ---
120 DIM font%(94)
130 FOR idx% = 0 TO 94 : READ font%(idx%) : NEXT
140 REM
150 REM --- Load sine table (64 entries) ---
160 DIM sine%(63)
170 FOR idx% = 0 TO 63 : READ sine%(idx%) : NEXT
180 REM
190 REM --- Get user input ---
200 INPUT "Enter scroll text: " text$
210 REM
220 REM --- Build padded buffer: 24 spaces + text + 24 spaces ---
230 pad$ = " " : REM 24 spaces
240 buf$ = pad$ + text$ + pad$
250 buflen% = LEN(buf$)
260 REM
270 PRINT "Scrolling with effects... press ESCAPE to stop"
280 offset% = 1 : REM text scroll position (1-based)
290 boff% = 0 : REM brightness wave offset
300 frame% = 0 : REM frame counter
310 REM
320 REPEAT
330 REM --- Paint the 24 visible characters ---
340 FOR col% = 0 TO 23
350 ch% = ASC(MID$(buf$, offset% + col%, 1)) - 32
360 IF ch% < 0 OR ch% > 94 THEN ch% = 0
370 PROCled(font%(ch%), col%)
380 NEXT
390 REM --- Animate the brightness wave 4 times per text step ---
400 REPEAT
410 FOR col% = 0 TO 23
420 PROCbright(sine%((col% + boff%) AND 63), col%)
430 NEXT
440 boff% = (boff% + 1) AND 63
450 frame% = (frame% + 1) AND 3
460 UNTIL frame% = 0
470 REM --- Advance text position, wrapping at the end ---
480 offset% = offset% + 1
490 IF offset% > buflen% - 23 THEN offset% = 1
500 UNTIL FALSE
510 END
520 REM
530 REM --- Write bitmask bm% to LED column col% via MBB_WRITE_LED ---
540 DEF PROCled(bm%, col%)
550 A% = col% : L% = bm% MOD 256 : H% = bm% DIV 256
560 CALL &FDD6
570 ENDPROC
580 REM
590 REM --- Set brightness br% (0-255) of LED column col% via MBB_LED_BRIGHTNESS ---
600 DEF PROCbright(br%, col%)
610 A% = col% : C% = br%
620 CALL &FDD3
630 ENDPROC
640 REM
650 REM --- Font DATA (ASCII 32-126, 95 entries) ---
660 DATA &0000, &4900, &0202, &12CE, &12ED, &2DE4
670 DATA &0B59, &0200, &0C00, &2100, &3FC0, &12C0
680 DATA &2000, &00C0, &4000, &2400
690 DATA &243F, &0406, &00DB, &008F, &00E6, &0869
700 DATA &00FD, &1401, &00FF, &00EF, &0040, &2200
710 DATA &0C40, &00C8, &2180, &5083
720 DATA &02BB, &00F7, &128F, &0039, &120F, &0079
730 DATA &0071, &00BD, &00F6, &1209, &001E, &0C70
740 DATA &0038, &0536, &0936, &003F
750 DATA &00F3, &083F, &08F3, &00ED, &1201, &003E
760 DATA &2430, &2836, &2D00, &00EE, &2409
770 DATA &0039, &0900, &000F, &2800, &0008
780 DATA &0100, &208C, &0878, &00D8, &208E, &2058
790 DATA &14C0, &048E, &1070, &1000, &2210
800 DATA &1E00, &1200, &10D4, &1050, &00DC
810 DATA &0170, &0486, &0050, &0888, &0078
820 DATA &001C, &2010, &2814, &2D00, &028E
830 DATA &2048, &2149, &1200, &0C89, &24C0
840 REM
850 REM --- Sine table (64 entries, values 0-255) ---
860 DATA &0080, &008C, &0098, &00A5, &00B0, &00BC, &00C6, &00D0
870 DATA &00DA, &00E2, &00EA, &00F0, &00F5, &00FA, &00FD, &00FE
880 DATA &00FF, &00FE, &00FD, &00FA, &00F5, &00F0, &00EA, &00E2
890 DATA &00DA, &00D0, &00C6, &00BC, &00B0, &00A5, &0098, &008C
900 DATA &0080, &0073, &0067, &005A, &004F, &0043, &0039, &002F
910 DATA &0025, &001D, &0015, &000F, &000A, &0005, &0002, &0001
920 DATA &0000, &0001, &0002, &0005, &000A, &000F, &0015, &001D
930 DATA &0025, &002F, &0039, &0043, &004F, &005A, &0067, &0073Now you can see that we've got two machine code procedures (one for displaying characters and one for setting brightness) and also two lookup tables now (one for the "font" and one for our sine-wave of brightness values).
Our character display loop (which starts at line 320) has changed quite a bit. It starts out as before, then at line 680 we have:
390 REM --- Animate the brightness wave 4 times per text step ---
400 REPEAT
410 FOR col% = 0 TO 23
420 PROCbright(sine%((col% + boff%) AND 63), col%)
430 NEXT
This goes through every column again, setting a suitable brightness value.
After that, we have this bit of chicanery:
440 boff% = (boff% + 1) AND 63
450 frame% = (frame% + 1) AND 3
What this is doing is incrementing both the brightness offset and the frame counter. When we increment the brightness offset, we AND 63 - this means that we keep only the bottom 6 bits of FC% so the effect is that whenever its value is 63 and we implement it, it wraps around to zero again.
This kind of modulo arithmetic with numbers that are a power of 2 is very, very common particularly in low level code like C or assembly language. The reason it's so ubiquitous is that a lot of maths operations in base 2 (binary) can easily be implemented with simple (and fast!) logic instructions that execute directly on the processor, like the AND we just saw. The alternative would be to do actual division and find the remainder, which is incredibly slow and tedious on old hardware like the z80. The z80 doesn't have a DIVIDE instruction, you'd have to write your own division routine.
We could of course use division in BASIC, but we'll come to why that's not such a great idea in a moment.
This should be second nature by now:
EFFECTS.BBC file from the repo across to your 'Beast's B driveB:BBCBASICLOAD "EFFECTS"LISTRUNAll being well, you should see this (your string might be different):

One thing you'll notice straight away is that it is monumentally slow. There's not even an artificial delay loop in there that we can tweak - this is running at full tilt! The sad truth is that while high-level languages like BASIC are great for learning how to code and writing simple programs, they squander a lot of the machine's power turning those fancy BASIC statements into machine code that the processor can execute.
To get more performance out of the processor (and believe me, it can go a lot faster!) we'll have to put aside BASIC, and like the bedroom-based game developers of yore teach ourselves z80 assembler..
That's it for Part Six, and also for BBC Basic!
In the next part Your name in lights! (Part 7) we'll learn some z80 assembler, by re-implementing the programs we've already written.
If you're not quite ready for that yet and want to experiment a bit further in BASIC then by all means do so: experimentation is the best way to learn!
If you want to dabble in other high-level languages you are spoilt for choice...there are CP/M implementations of Algol, COBOL, Fortran, Pascal, LISP, Forth, and C, and probably many more besides. These are easily found on the web, and most will run on the 'Beast without issue (just make sure it's the z80 + cp/m 2.2 version you're trying to run.
Well done for making it this far - the real fun is about to begin!
Last time in Your name in lights! (Part 5), we get a basic scrolltext working and were able to scroll any message of our choosing. We're going to be building on that foundation here, so if you skipped earlier parts or are still a little shaky on the

Last time in Your name in lights! (Part 4) , we were able to display arbitrary short strings on the 'Beast's LED displays.
We're going to be building on that foundation here, so if you skipped earlier parts or are still a little shaky on the fundamentals you might want to go back and read them again.
To move forward, I'm going to assume that you've got SLIDE.COM on the B: drive of your 'Beast, and that you have the corresponding PC utility installed somewhere in your PATH on your development PC. Refer to the SLIDE README if you need more help with that.
I'm also going to assume that you have BBCBASIC.COM on your B drive.
First things first: clone the Beast User repository on to your development PC.
We're going to start in the scrolltext/bbc_basic/scrolltext folder.
This time around, we're going to add the ability to display strings that are longer than our display is wide (24 characters). And we'll do that by making a "scroll text" - only a portion of the message is visible at a given time. By updating which portion we display ever so slightly on a regular basis, we can give the illusion of the text scrolling by.
Imagine for a moment, that you are wearing a welding helmet and contemplating God's final message to his creation. You can't see the whole message in one go, you'd have to physically turn your head to read it (or move the message of course, but it's not so easy to move divine messages in 30 foot letters made of fire).

One way we could do this is to start by displaying our string at column 0, and then on the next iteration display it at column -1, then -2 etc. etc. The text would then appear to be moving to the left:

This sort of approach is common in many graphics systems, but the 'Beast will not take kindly to negative column values. Also, it is not a very efficient technique: if the string is very long, we will spend a lot of time trying to render characters that cannot be visible.
A better approach is to slide the display along the string:

So we start by displaying the first 24 characters of the string starting at the first (index 0), but on the next iteration we display 24 characters starting from the second position in the string (index 1) and so on. We could do some complicated maths to deal with what happens when we get to the end of the string, but it's easier to just stick 24 spaces on the end. In fact, we'll stick another 24 on the front so the string appears to enter from the right hand edge.
Here's the code we'll be running this time:
10 REM === MicroBeast LED Demo - Step 4: Scrolling Text (BBC BASIC) ===
20 REM Prompts for a string and scrolls it continuously across the
30 REM 24-character LED display. The text is padded with spaces so it
40 REM scrolls in from the right and out to the left.
50 REM
60 REM --- Load font data into an array (ASCII 32-126) ---
70 DIM font%(94)
80 FOR idx% = 0 TO 94 : READ font%(idx%) : NEXT
90 REM
100 REM --- Get user input ---
110 INPUT "Enter scroll text: " text$
120 REM
130 REM --- Build padded buffer: 24 spaces + text + 24 spaces ---
140 pad$ = " " : REM 24 spaces
150 buf$ = pad$ + text$ + pad$
160 buflen% = LEN(buf$)
170 REM
180 PRINT "Scrolling... press ESCAPE to stop"
190 offset% = 1 : REM scroll offset (1-based for MID$)
200 REM
210 REPEAT
220 REM Display 24 characters starting at the current offset
230 FOR col% = 0 TO 23
240 ch% = ASC(MID$(buf$, offset% + col%, 1)) - 32
250 IF ch% < 0 OR ch% > 94 THEN ch% = 0
260 PROCled(font%(ch%), col%)
270 NEXT
280 REM Delay for scroll speed (centiseconds); also lets ESCAPE break
290 dummy% = INKEY(8)
300 REM Advance scroll position, wrapping at the end
310 offset% = offset% + 1
320 IF offset% > buflen% - 23 THEN offset% = 1
330 UNTIL FALSE
340 END
350 REM
360 REM --- Write bitmask bm% to LED column col% via MBB_WRITE_LED ---
370 DEF PROCled(bm%, col%)
380 A% = col%
390 L% = bm% MOD 256
400 H% = bm% DIV 256
410 CALL &FDD6
420 ENDPROC
430 REM
440 REM --- Font DATA (ASCII 32-126, 95 entries) ---
450 DATA &0000, &4900, &0202, &12CE, &12ED, &2DE4
460 DATA &0B59, &0200, &0C00, &2100, &3FC0, &12C0
470 DATA &2000, &00C0, &4000, &2400
480 DATA &243F, &0406, &00DB, &008F, &00E6, &0869
490 DATA &00FD, &1401, &00FF, &00EF, &0040, &2200
500 DATA &0C40, &00C8, &2180, &5083
510 DATA &02BB, &00F7, &128F, &0039, &120F, &0079
520 DATA &0071, &00BD, &00F6, &1209, &001E, &0C70
530 DATA &0038, &0536, &0936, &003F
540 DATA &00F3, &083F, &08F3, &00ED, &1201, &003E
550 DATA &2430, &2836, &2D00, &00EE, &2409
560 DATA &0039, &0900, &000F, &2800, &0008
570 DATA &0100, &208C, &0878, &00D8, &208E, &2058
580 DATA &14C0, &048E, &1070, &1000, &2210
590 DATA &1E00, &1200, &10D4, &1050, &00DC
600 DATA &0170, &0486, &0050, &0888, &0078
610 DATA &001C, &2010, &2814, &2D00, &028E
620 DATA &2048, &2149, &1200, &0C89, &24C0No surprises here. We've essentially got all the same code as last time, but now we've got a loop around our main display routine that changes the offset on each iteration: lines 220-330. The delay in line 290 is just there to slow things down a bit, lest the awesome power of BBC BASIC render our message an illegible blur.
You know the drill:
SCROLLTEXT.BBC file from the repo across to your 'Beast's B drive (you'll need to rename it as SCROLTXT.BBC to fit the CP/M naming convention):BBCBASICLOAD "SCROLTXT"LISTRUNAll being well, you should see this (your string might be different):

That's it for Part Five - we built on all the understanding we've developed so far and built a reasonable scrolltext implementation.
In the next part, Your name in lights! (Part 6),we'll wrap up the BASIC section by adding some effects to our scrolltext to make it extra fancy!
Last time in Your name in lights! (Part 4) , we were able to display arbitrary short strings on the 'Beast's LED displays. We're going to be building on that foundation here, so if you skipped earlier parts or are still a little shaky on the

Last time, in Your name in lights! (Part 3) we really got into our stride and managed to write short (very short!) messages of our choosing to the LED display.
We're going to be building on that foundation here, so if you skipped earlier parts or are still a little shaky on the fundamentals you might want to go back and read them again.
To move forward, I'm going to assume that you've got SLIDE.COM on the B: drive of your 'Beast, and that you have the corresponding PC utility installed somewhere in your PATH on your development PC. Refer to the SLIDE README if you need more help with that.
I'm also going to asssume that you have BBCBASIC.COM on your B drive.
First things first: clone the Beast User repository on to your development PC.
We're going to start in the scrolltext/bbc_basic/strings folder.
This time, we'll modify the code we already have slightly to allow us to display longer messages, and to let us input the string we want to display dynamically, rather than hard-coding it into the source code.
Here's the code we'll be running this time:
10 REM === MicroBeast LED Demo - Step 3: String Display (BBC BASIC) ===
20 REM Prompt the user for a string and display it on the 24-char
30 REM LED display. Each character is converted to its 14-segment
40 REM bitmask via a font lookup.
50 REM
60 REM --- Load font data into an array (ASCII 32-126) ---
70 DIM font%(94)
80 FOR idx% = 0 TO 94 : READ font%(idx%) : NEXT
90 REM
100 REM --- Get user input ---
110 INPUT "Enter text (max 24 chars): " text$
120 IF LEN(text$) > 24 THEN text$ = LEFT$(text$, 24)
130 REM
140 REM --- Display the string, padding the rest with blanks ---
150 FOR col% = 0 TO 23
160 IF col% < LEN(text$) THEN ch% = ASC(MID$(text$, col%+1, 1)) - 32 ELSE ch% = 0
170 IF ch% < 0 OR ch% > 94 THEN ch% = 0
180 PROCled(font%(ch%), col%)
190 NEXT
200 PRINT "Done!"
210 END
220 REM
230 REM --- Write bitmask bm% to LED column col% via MBB_WRITE_LED ---
240 DEF PROCled(bm%, col%)
250 A% = col%
260 L% = bm% MOD 256
270 H% = bm% DIV 256
280 CALL &FDD6
290 ENDPROC
300 REM
310 REM --- Font DATA (ASCII 32-126, 95 entries) ---
320 DATA &0000, &4900, &0202, &12CE, &12ED, &2DE4
330 DATA &0B59, &0200, &0C00, &2100, &3FC0, &12C0
340 DATA &2000, &00C0, &4000, &2400
350 DATA &243F, &0406, &00DB, &008F, &00E6, &0869
360 DATA &00FD, &1401, &00FF, &00EF, &0040, &2200
370 DATA &0C40, &00C8, &2180, &5083
380 DATA &02BB, &00F7, &128F, &0039, &120F, &0079
390 DATA &0071, &00BD, &00F6, &1209, &001E, &0C70
400 DATA &0038, &0536, &0936, &003F
410 DATA &00F3, &083F, &08F3, &00ED, &1201, &003E
420 DATA &2430, &2836, &2D00, &00EE, &2409
430 DATA &0039, &0900, &000F, &2800, &0008
440 DATA &0100, &208C, &0878, &00D8, &208E, &2058
450 DATA &14C0, &048E, &1070, &1000, &2210
460 DATA &1E00, &1200, &10D4, &1050, &00DC
470 DATA &0170, &0486, &0050, &0888, &0078
480 DATA &001C, &2010, &2814, &2D00, &028E
490 DATA &2048, &2149, &1200, &0C89, &24C0Much of this code should look familiar from last time. We've got the same machine-code stub to call our BIOS routine in lines 240-290, the same font table setup in lines 70-80, and the same (ish) character display routine in lines 150-190.
We've added some code in lines 110-120 to prompt the user for the string to be displayed:
110 INPUT "Enter text (max 24 chars): " text$
120 IF LEN(text$) > 24 THEN text$ = LEFT$(text$, 24)
INPUT is the keyword that causes the prompt to be displayed, and whatever characters you provide are stored in the variable text$ (remember that $ means "string" here).
The second line is a safety check to ensure that our string is no longer than 24 characters, because that's the maximum message size we can display on our 24-character LED display.
There's also some extra chicanery in lines 160-170 that I glossed over earlier that we should now review:
160 IF col% < LEN(text$) THEN ch% = ASC(MID$(text$, col%+1, 1)) - 32 ELSE ch% = 0
170 IF ch% < 0 OR ch% > 94 THEN ch% = 0col% is our integer "column number" variable counting up from 0 to 23 inclusive. For each column position we extract the relevant character from the string - that's the MID$ keyword in the middle. We use ASC to get the ASCII character code for this character, and we subtract 32 from it, because we don't have font table entries for the first 32 ASCII characters as they're all control codes anyway. The first entry in our font table is the SPACE character, whose ASCII value is 32: so subtracting 32 is an easy way to convert from ASCII code to font-table index.
Line 160 is also guarding against our input string being shorter than the display width (IF col% < LEN(text$)) - if we run out of characters in text$ before we get to the end of the display we do ELSE ch% = 0 which has the effect of using entry 0 in our font table (a blank SPACE character).
Finally line 170 guards against tricksy character values that our outside the bounds of our font table: if that happens, we'll swap those out for a SPACE character too.
You know the drill:
STRINGS.BBC file from the repo across to your 'Beast's B driveA:BBCBASICLOAD "STRINGS"LISTRUNAll being well, you should see this (your string might be different):

That's it for Part Four - another relatively short one. Next time, in Your name in lights! (Part 5) we'll look at how we can display strings that are longer than the available display width, and introduce the ancient and venerable art of ScrollTexts...
Last time, in Your name in lights! (Part 3) we really got into our stride and managed to write short (very short!) messages of our choosing to the LED display. We're going to be building on that foundation here, so if you skipped earlier parts or are still

Last time in Your name in lights! (Part 2) , we got to grips with BBC Basic and managed to emit some weird runes onto our LED display.
We're going to be building on that foundation here, so if you skipped Part One or Part Two or are still a little shaky on the fundamentals you might want to go back and read them again.
To move forward, I'm going to assume that you've got SLIDE.COM on the B: drive of your 'Beast, and that you have the corresponding PC utility installed somewhere in your PATH on your development PC. Refer to the SLIDE README if you need more help with that.
I'm also going to assume that you have BBCBASIC.COM on your B drive.
First things first: clone the Beast User repository on to your development PC.
We're going to start in the scrolltext/bbc_basic/fonts folder.
This time, we'll see if we can adopt that classic time-honoured writing system: the Latin alphabet!
All of the standard characters on the 'Beast can be represented by an ASCII code: for example the letter A is character number 65 (0x41). It would be convenient to use these values when we're trying to write every-day text to our display, and only resort to codewords when we want a Space Invader, or something.

You might guess from the Font Editor I've shown you previously that this involves creating a "Font" which is simply a big table of codewords for every letter, digit or punctuation mark we wish to use, systematically organised in such a way that we can convert from ASCII codes to LED code-words.

You might also be thinking "Hold on, the 'Beast already displays characters perfectly well, surely someone has already done this work?" - and you'd be right. The 'Beast's designers have already provided a "font" that covers the 94 most exciting characters in the ASCII standard. (ASCII only defines 128 characters in total, and some of those are special control characters that can't be printed to the screen anyway).
You can see the MicroBeast's font table on github.
Unfortunately, the BIOS doesn't provide a convenient way to access this font table from code running on the `Beast. We could figure out its address in the particular firmware we're using, but such an approach is brittle because there are no guarantees that the next version of the firmware will have the font table at exactly the same address, and our code would break.
The alternative is to define our own font table. Rather than mess about defining 94 characters in the Font Editor, we're just going to copy the info from the MicroBeast font table into our own code.
Here's the code we'll be running this time:
10 REM === MicroBeast LED Demo - Step 2: Font Rendering (BBC BASIC) ===
20 REM Display "HELLO" on the last 5 LED positions (columns 19-23)
30 REM using font bitmask data for the 14-segment displays.
40 REM
50 REM Each character has a 16-bit bitmask:
60 REM Low byte = outer segments (a,b,c,d,e,f,g1,g2)
70 REM High byte = inner/diagonal segments (h,j,k,l,m,n)
80 REM
90 REM The font table is indexed from ASCII 32 (space) onwards:
100 REM index = ASC(char) - 32, bitmask = font%(index)
110 REM
120 REM --- Load font data into an array (ASCII 32-126, 95 entries) ---
130 DIM font%(94)
140 FOR idx% = 0 TO 94 : READ font%(idx%) : NEXT
150 REM
160 REM --- Display "HELLO" on columns 19-23 ---
170 text$ = "HELLO"
180 FOR pos% = 1 TO 5
190 ch% = ASC(MID$(text$, pos%, 1)) - 32
200 PROCled(font%(ch%), 18 + pos%)
210 NEXT
220 PRINT "Displayed HELLO on columns 19-23"
230 END
240 REM
250 REM --- Write bitmask bm% to LED column col% via MBB_WRITE_LED ---
260 DEF PROCled(bm%, col%)
270 A% = col%
280 L% = bm% MOD 256
290 H% = bm% DIV 256
300 CALL &FDD6
310 ENDPROC
320 REM
330 REM --- Font DATA (ASCII 32-126, 95 entries) ---
340 REM Each value is a 16-bit bitmask for the 14-segment display
350 REM
360 DATA &0000, &4900, &0202, &12CE, &12ED, &2DE4
370 DATA &0B59, &0200, &0C00, &2100, &3FC0, &12C0
380 DATA &2000, &00C0, &4000, &2400
390 DATA &243F, &0406, &00DB, &008F, &00E6, &0869
400 DATA &00FD, &1401, &00FF, &00EF, &0040, &2200
410 DATA &0C40, &00C8, &2180, &5083
420 DATA &02BB, &00F7, &128F, &0039, &120F, &0079
430 DATA &0071, &00BD, &00F6, &1209, &001E, &0C70
440 DATA &0038, &0536, &0936, &003F
450 DATA &00F3, &083F, &08F3, &00ED, &1201, &003E
460 DATA &2430, &2836, &2D00, &00EE, &2409
470 DATA &0039, &0900, &000F, &2800, &0008
480 DATA &0100, &208C, &0878, &00D8, &208E, &2058
490 DATA &14C0, &048E, &1070, &1000, &2210
500 DATA &1E00, &1200, &10D4, &1050, &00DC
510 DATA &0170, &0486, &0050, &0888, &0078
520 DATA &001C, &2010, &2814, &2D00, &028E
530 DATA &2048, &2149, &1200, &0C89, &24C0
The aim this time around is to write the word "HELLO" in the last 5 characters of the display. We won't be writing code-words either this time: we'll use ASCII characters.
You can see in line 170:
170 text$ = "HELLO"
The $ suffix means "this variable is a string", and a "string" is a sequence of ASCII characters. It's more convenient than writing out 72, 69, 76, 76, 79, but is otherwise exactly equivalent (bar some sneaky extra information that is stored to remember how long the string is).
In line 130, we have this odd looking line:
130 DIM font%(94)
This means that we're "DIMensioning" (allocating) an "array" (list or table) of 94 integers (because of the %), and we want to call this table font% for "Font Table".
In line 140 you can see where we're setting up values to go in to that table. We loop around 94 times and for each character we're performing:
140 FOR idx% = 0 TO 94 : READ font%(idx%) : NEXT
so as I% goes from 0 to 94 we'll first read a value into font%(0) (the first slot) then the next value into font%(1) and so on. That READ statement gets its data from the DATA statements starting at line 360.
The font data exactly matches the MicroBeast firmware file I showed you earlier - the hexadecimal numbers are just formatted in a slightly different way.
We can speed through this now, as you're an old hand at running BASIC programs on the 'Beast. Try this:
FONTS.BAS file from the repo across to your 'Beast's B driveA:BBCBASICLOAD "FONTS"LISTRUNAll being well, you should see this:

That's it for Part Three - nice and quick this time!
Next time, in Your name in lights! (Part 4) we'll let you type in any string you like (within reason) and have that displayed on the LEDs!
microbeast
Last time in Your name in lights! (Part 2) , we got to grips with BBC Basic and managed to emit some weird runes onto our LED display. We're going to be building on that foundation here, so if you skipped Part One or Part Two or are still

In Your name in lights! we got to grips with the 'Beast and its hardware and got comfortable transferring disk images onto the 'Beast using Y-Modem.
We're going to be building on that foundation here, so if you skipped Part One or are still a little shaky on the fundamentals you might want to go back and read it again.
To move forward, I'm going to assume that you've got SLIDE.COM on the B: drive of your 'Beast, and that you have the corresponding PC utility installed somewhere in your PATH on your development PC. Refer to the SLIDE README if you need more help with that.
First things first: clone the Beast User repository on to your development PC.
We're going to start in the scrolltext/bbc_basic/leds folder.
I already briefly mentioned the LEDs on the 'Beast. To recap, there are 24 characters, and each character is made of 15 different LEDs that we can light individually, to make a symbol that we recognize as a letter, numeral, or punctuation.

In other words there are 15 different LEDs that are either on or off for each of the 24 characters. So we can represent one character as a 16-bit word where each bit controls an LED, and we can represent the whole character array as a table or list of 24 16-bit words.
Here are the bit values that will activate each LED segment:

To light multiple segments, we combine their bits. This is technically an OR operation, but you can think of it as simply adding them altogether to come up with a number that represents the symbol we want.

You're not limited to the standard boring ANSI fare: you can have any symbol that you can dream up, subject to the (rather restrictive!) geometry of the LEDs themselves.
You can use my MicroBeast Font Editor to play with this ( instructions ) and see how symbols get turned into 16-bit words. If you come up with something that really tickles your fancy, make a note of its "control word" and use that in the examples that follow instead of the default value I'll be giving you.

Once we know the value for our symbol, what do we do with it? How do we get it on the display? Luckily, the designers of your 'Beast have got your back: they provide a BIOS call that takes a word describing the symbol and a column number as parameters, and does a lot of complicated hardware manipulation behind the scenes. We can treat it as a "black box": we know precisely what it does, and we know what inputs (parameters) it needs to accomplish this, but we don't care how it does it.

This is a machine code routine, so to pass parameters (the symbol we want and the column we want it in) we have to set up some z80 registers to contain those values. One of these is called HL - it contains the 16-bit word that describes the symbol - and the other is called A - it contains the column number, a value between 0 and 23 (because there are 24 columns on the LED display, and we start counting at 0).
Don't worry about what registers are, and why some of them have single character names and some of them have two character names - we'll get into that later. For now, just think of them as variables you can set, before calling the BIOS routine (think of it as a procedure or a function or even a GOSUB that does the work). So we're essentially saying:
HL to the 16-bit word that describes the symbol we wantMBB_WRITE_LED (0xFDD6) to do the workThis kind of encapsulation and re-use is fundamental to pretty much all forms of software development; often described as the DRY principal: Don't Repeat Yourself. (The opposite, of course, is Write Everything Twice...)
The disk image I've provided contains BBCBASIC.COM Which is Russel T. Davis' BBC BASIC Z80 v5.0, from 2025! We can go ahead and type BBCBASIC to start it up:

You can have a little play, if you like:

Type RUN to execute the program (and hit Escape when you want it to stop):

There was a period in the 1980s when every display in every computer shop in Bedford town centre looked exactly like this!
By the way, to get out of BASIC and get back to CP/M, type *QUIT and hit ENTER.
You might imagine that calling the BIOS routine we're interested in is as simple as setting up our HL and A variables and using some sort of keyword that means "call some machine code at an address I specify":
REM this is not real code
HL = 65535
A = 0
CALL 0xFDD6
and BBC Basic does indeed provide a CALL keyword that does exactly this, with a couple of caveats:
A, B, C, D, E, F, H and L are initialised to the least significant words of the integer variables A%, B%, C%, D%, E%, F%, H% and L% respectively.& in BBC BASIC, so &FDD6.So unlike Microsoft BASIC, we don't need to write any special machine code routines to act as a stub - we can set all the z80 registers directly.
This makes the examples vastly simpler and easier to understand with all that added clutter.
In lines 120 to 140 of LEDS.BBC we're looping over columns 20 to 23 (that's the four right-most ones on the display) and writing our funky symbol to them. PROCled(bm%, col%) is a procedure (named subroutine) that wraps our machine code call, which is line 230. The CALL &ffD6 is the bit that calls the BIOS routine directly: the procedure already setup the A%, H%, and L% variables, which will get passed to the A, H, and L registers.
Let's give it a go!
Fire up your 'Beast and SLIDE LEDS.BBC over to your B: drive. Make sure you're "logged in" to the A: drive (that's CP/M jargon that means your prompt says A> - if it doesn't type A: and hit ENTER). Type BBCBASIC to start the BASIC interpreter, and then type LOAD "B:LEDS" to load the demo program. You can type LIST to examine it if you like, and when you're ready type RUN and hit ENTER:

You should be rewarded with this splendid display:

Try changing the code-word to display a different symbol. You can replace line 130 by typing e.g. 130 PROCled(..., col%) and inserting your new value. Type RUN to try it.
Try writing to all the columns, from column 0 on the left to column 23 on the right. Be aware that the console output might over-right the leftmost columns (that's why I chose the rightmost ones for my demo!).
Try writing a different symbol to each column.
When you're done, remember that *QUIT exits BBC Basic and returns you to CP/M.
So far so good - we learned a lot of stuff about BBC Basic and actually managed to write some code and run it on the 'Beast! But funky symbols can only keep us amused for so long.
Join me in Your name in lights! (Part 3) where we can start to put recognisable characters on the display!
microbeast
In Your name in lights! we got to grips with the 'Beast and its hardware and got comfortable transferring disk images onto the 'Beast using Y-Modem. We're going to be building on that foundation here, so if you skipped Part One or are still a little

A Z80 can only see 64 KB at once. The MicroBeast retro computer has 512 KB of RAM. Phase 4 of AntForth was about closing that gap: letting a single Forth session compile words into, and call words across, banks of memory that don't all fit in the processor's address space at the same time.
This post is the story of that phase. It ships as antforth v3.0.7, the first feature release since v2.0. I'll lead with the architecture and the things that went wrong (there were a few good ones), and weave in how we used BMAD — an agentic, spec-driven development method — to orchestrate the whole thing without losing the thread.
If you write Forth or Z80 machine code, there are deeper sidebars for you. If you're here for the "how do you actually build something complex with AI agents" angle, the connective tissue is the BMAD loop, and you can skip the hex.
The naive way to use more memory is a flat map — every byte has one address. That's impossible here: 512 KB doesn't fit in 16 bits.
The MicroBeast's MMU solves this the classic 8-bit way. The Z80's 64 KB is divided into four 16 KB slots. Each slot displays one 16 KB page chosen from a 6-bit page space (64 pages × 16 KB = 1 MB of addressable pages, of which 512 KB is real RAM). Change a slot's page register and the same Z80 addresses now read and write different physical memory.
Three slots stay nailed down: the kernel lives in slots 0 and 1, and the stacks, the CP/M residency, BDOS and BIOS live in slot 3. Slot 2 ($8000–$BFFF) is the window. Banking is, fundamentally, the discipline of deciding which page is in the window right now, and making the Forth dictionary behave sanely as that page changes underneath it.
The default configuration gives you 12 banks (192 KB). Sacrifice the virtual console and the RAM disk and you can push to a theoretical 29 banks (464 KB) — all surfaced to the user through one word, BANK!.
5 BANK! \ map page for bank 5 into the window
: GREET ." hi from bank 5" ;
0 BANK! \ back to bank 0
GREET \ still works — prints "hi from bank 5"
That last line is the whole problem in miniature. When you type GREET from bank 0, its code isn't currently mapped in. Something has to notice, swap bank 5 into the window, run it, and swap back — without the user ever knowing. How we got there is the rest of this story.
Banking needs bookkeeping memory that's always visible regardless of which bank is in the window. Phase 4's first real decision (Epic 16) was where to put it. The answer, verified on real hardware: evict the CP/M CCP.
The CCP — the command-line shell — sits at $D400–$DBFF and is reloaded from disk on every warm boot anyway. That's 2 KB of fixed memory we could take for free. Story 16.1 confirmed on silicon that consuming it is safe (warm-boot reloads it), which unlocked everything downstream.
Each bank has its own dictionary growing inside the window. So each bank needs its own copy of three Forth pointers:
HERE — where the next compiled byte goesLATEST — the most recently defined wordWe call this the triple. On BANK!, antforth saves the live triple into bank-table[old] and loads bank-table[new]. Standard stuff.
Sidebar — the invariant that saved us repeatedly. The triple is only those three pointers. The hash bucket array thatFINDwalks is global and shared — it is not swapped per bank. This sounds like a bug waiting to happen, and reviewers kept "finding" it. It's actually the load-bearing correctness property of the whole design: because the buckets are shared andFIND's page-in is address-conditioned (any address below$8000is fixed memory, so it's reachable without paging), a word linked into the chain stays findable from every bank. Swaps move the triple; they never touch findability. Internalising this turned a class of scary-looking "triple corruption" review findings into empirically-refutable non-issues — more on that below.
When you define a word in bank 5, you can't just store its address — that address ($8000-something) means a different physical location depending on what's in the window. So every banked word gets a 4-byte descriptor stub in fixed memory. The stub records "I live in bank 5 at offset X." Executing the word routes through its stub, which maps the right page in, runs the body, and restores the window.
The dictionary pointers that reach these words are 24-bit fat pointers: two bytes of address plus one byte of bank ([addr:2][bank:1]). That one extra byte per link is what lets FIND know whether a hit lives in fixed memory or out in a bank — and it's the single biggest line item in the byte budget (Epic 20, below).
Here's the connective tissue. antforth isn't built in a freewheeling "chat until it compiles" style. Every increment runs through a structured, auditable loop:
A few things make this work at the scale of a 38-story phase:
create-story pass doesn't just list acceptance criteria — it pre-reads the live source, pins down exact file:line citations, and surfaces findings the dev pass must not re-discover. Several Phase-4 stories opened with five or six "load-bearing findings resolved at draft time." That front-loading is why dev passes land cleanly.EVALUATE source-id leak, a bucket-scrub bug). The discipline is to refute findings empirically — run the code, watch the pointers — rather than argue.The human (the project lead) stays in the loop at exactly the decision points that matter: mechanism elections, envelope dispositions, tag application. The agents do the reading, drafting, implementing, and reviewing; the human makes the calls that need judgement.
No interesting phase is monotonic. Here are the three detours worth remembering.
The first dispatch design (Epic 18) used a sentinel trampoline: a cross-bank call pushed a fake return address that pointed at a little fixed-memory routine, which would restore the window when the word "returned" to it. It worked in the emulator. It was also fragile in a way that took an entire interlude to understand.
The symptom was intermittent hangs. The suspected cause cycled through "kernel too big," "emulator quirk," "trampoline layout." The actual root cause (ADR 19.5) was something else entirely: portal-window dictionary aliasing. When a word's body sat above $8000 and a foreign bank was mapped, a dictionary lookup could walk the shared bucket chain through the window and read a foreign page. The trampoline was innocent. The kernel size was never causal.
This earned its own stabilization interlude — Epic 19.5 — rather than being smuggled into a feature epic. That framing decision mattered: the interlude got its own stories, its own retro, and its own release tag (v3.0.4), which is why the whole downstream version mapping shifted by one and Phase 4 ended on v3.0.7 instead of v3.0.6.
The fix was elegant in the way good 8-bit code often is: make the cost disappear. The sentinel trampoline was replaced by an RST $28 self-dispatching stub. Each banked word's stub is its own dispatcher; the inner interpreter's NEXT doesn't need a discriminator added to it at all.
Sidebar — 0 T-states per NEXT. The headline result (Epic-19.5 DR-2): cross-bank dispatch adds zero cycles to the inner loop. The plain "pop and continue" path is the only path. ABANK!itself costs around 425 T-states (most of it the triple swap and one MMU port write); an intra-bank call costs exactly one extraJPversus a flat build. For a mechanism that lets you address half a megabyte, the steady-state overhead is essentially a rounding error.
Midway through, antforth was writing MMU page registers with direct OUT instructions. On real hardware this fought the BIOS, which keeps its own shadow of the page state and re-pages under interrupt. The MMU page ports turned out to be write-only by design — any attempt to read them back floats.
The pivot (the "BIOS-MBB" change) was to stop poking ports and route every page change through two blessed BIOS entry points: MBB_SET_PAGE ($FDDF) and MBB_GET_PAGE ($FDDC). The BIOS keeps the shadow coherent and survives interrupts. This is the kind of correction that only shows up on silicon, and it's the reason the hardware-UAT gate is non-negotiable.
A Forth where banking is both real and legible. The final epic (Epic 22) added no new mechanism — it made the mechanism observable.
.BANKS
\ BANK PAGE USED FREE
\ 0 22 * 1843 25685 <- bank 0 = kernel dictionary
\ 5 39 0 16384
\ ...
\ TOTAL 1843 205909
\ BANKED-WORDS 0
\ STUB-BYTES 0
The REPL can show which bank you're about to type into (opt-in, so the classic prompt is untouched for everyone else):
-1 PROMPT-SHOW-BANK \ enable the indicator
5 BANK!
[5] ok \ the prompt now tells you where you are
0 BANK!
ok \ bank 0 is suppressed — a [0] prompt is just noise
And the cross-bank machinery is end-to-end: : lands its body in the current bank and auto-emits a stub; CREATE/DOES> work across banks; MARKER/FORGET revert per-bank dictionary tails and reclaim stubs; ABORT and THROW restore your interactive bank instead of stranding you; and CODE words are redirected to fixed memory so assembler words stay callable from anywhere.
Every byte was measured from a clean rebuild and accepted (or questioned) at its own story close. The phase grew the kernel by +3,504 bytes. Each row below is a fresh clean build of that release tag; the deltas sum to the total:
| Milestone | Tag | Kernel size | Δ vs prev | Highlight |
|---|---|---|---|---|
| Phase-3 close | v2.0.0 | 24,995 B | — | flat-memory baseline |
| Epic 17 | v3.0.1 | 26,228 B | +1,233 B | bank table, BANK@/BANK!, CL parser |
| Epic 18 | v3.0.2 | 26,477 B | +249 B | descriptor-stub allocator + trampoline |
| Epic 19 | v3.0.3 | 26,834 B | +357 B | per-bank HERE, bank-aware : / CREATE |
| Epic 19.5 | v3.0.4 | 26,945 B | +111 B | RST-$28 self-dispatch; trampoline retired |
| Epic 20 | v3.0.5 | 27,888 B | +943 B | 24-bit fat pointers (FIND / WORDS) |
| Epic 21 | v3.0.6 | 28,049 B | +161 B | MARKER / FORGET / state restore |
| Epic 22 | v3.0.7 | 28,499 B | +450 B | .BANKS, prompt, CODE redirect |
Sidebar — the envelope multiplier. Pure-addition stories in Phase 4 reliably overran their first-cut byte estimates by roughly 2.4× — enough that we started budgeting with the multiplier. The exception proves the rule: Epic 20's +943 B (the table's biggest single jump) overran its estimate by about 3.4×, because swapping 16-bit dictionary links for 24-bit fat pointers was a mechanism substitution that rippled through every lookup path — not a pure addition. "Substitution voids the multiplier" became a planning rule, carried in project memory and validated when the pure-addition epics that followed tracked close to their estimates again.
The total banking infrastructure — code plus the reclaimed-CCP structures — is 5,552 bytes, comfortably inside the ~6 KB target at the default 12 banks and well under the 8 KB cap at 29. The descriptor-stub region holds 461 slots and sits at 0/461 at boot: stubs are a runtime/lifecycle cost, not a fixed tax.
These are actual elapsed calendar dates pulled from git history — not effort estimates. Phase 4 ran from the first Epic-16 commit on 2026-05-13 to the v3.0.7 tag on 2026-06-14: 32 days, 38 stories across 8 sub-tracks.
The calendar was anything but evenly spread, and its shape retells the same story the war-stories did:
| Epic | Span | Stories | Where the time went |
|---|---|---|---|
| 16 memory map | May 13–15 | 4 | research + hardware verification (CCP eviction) |
| 17 banking core | May 15–17 | 8 | bank-table, BANK!/BANK@, CL parser, first .BANKS |
| 18 dispatch scaffold | May 17–18 | 6 | descriptor stubs + the (doomed) trampoline |
| 19 per-bank compiler | May 18 – Jun 4 | 5 | the grind — bank-aware : / CREATE / DOES> |
| 19.5 stabilization | Jun 4–10 | 5 | the interlude — root-cause + RST-$28 rework |
| 20 find / words | Jun 10–12 | 3 | 24-bit fat pointers |
| 21 lifecycle | Jun 12–13 | 3 | MARKER / FORGET / state restore |
| 22 polish + close | Jun 13–14 | 4 | .BANKS final, prompt, CODE, retros, tag |
Three things jump out of that table:
The lopsidedness is the lesson: in a banked-memory port, the hard part isn't the breadth of the wordset — it's the one mechanism everything else stands on.
Three things I'd carry into any phase like this:
Phase 4 was a rocky road with an easy finish. The bet — that a 64 KB Z80 could be taught to use half a megabyte through one honest little window, and that an AI-orchestrated, spec-driven loop could build it correctly on real silicon — paid off. antforth v3.0.7 is on the MicroBeast, addressing all of it.
antforth
How we added banked-RAM support to a CP/M Forth on the MicroBeast — the architecture, the war stories, and the AI-orchestrated process that held it together.
LLM
I'm a huge fan of the BMAD Method using Claude Code on a Claude Pro Max subscription, but one bit of advice I've been ignoring for a while now is that the Code Review pass should be performed with a different LLM. When you're
Epic 12 is all about vocabularies and search orders. Up to now, all AntForth words (be they system defined or user defined) have lived in a single dictionary. You may recall that the dictionary is a simple hash-based mechanism that manages 64 separate lists of words: the hashing function balances the allocation of words between them.
Epic 12 allows us to have multiple dictionaries, to control the order in which they are searched, and to control to which dictionary new word definitions will be written.
This will make AntForth compliant with the ANS Forth Search-Order word set, and it also implements a word from the Search-Order extensions word set (ONLY).
Many of the other words in the Search-Order extensions are the ancient precursors of modern search-order support, which is now effectively obsolete, the most familiar is probably ALSO. Curiously there is no corresponding VOCABULARY word, without which ALSO makes no sense.
In fact I probably shouldn't use the word "vocabulary" at all as it is synonymous with the old ways, but it is semantically correct.
FORTH-WORDLISTPreviously all of AntForth's built-in words lived in the single, anonymous dictionary. In a new multi-dictionary world, their dictionary is but one of many. To get a handle on this dictionary we use the new word FORTH-WORDLIST.
This is essentially a dictionary hash management structure (64 pointers to lists) plus a link pointer to the next wordlist in the chain. The value returned is called a wid (word-list identifier).
WORDLISTWORDLIST is how you create and allocate a new wordlist. It allocates 130 bytes for the hash-list dictionary structure, initialises it with sane values, and returns the address of this structure in TOS as a wid.
SEARCH-WORDLISTSEARCH-WORDLIST allows you to search a specific word list (and not the entire search order) for the word identified by the given string. If the word exists, it returns the xt for that word, along with a 1 if the word is immediate, or a -1 if it is not.
SEARCH-WORDLIST takes the address and count of the name, plus the wid of the word list to be searched.

GET-ORDERGET-ORDER returns a list of wids that comprise the current search order to the stack, along with the current search depth.
When AntForth has just started, the search-order only contains a single wid ( that for FORTH-WORDLIST) and the search depth is 1.

SET-ORDERSET-ORDER performs the reverse operation to GET-ORDER: it takes a search depth and a list of wids from the stack and writes the search-order list from them. You can have up to 16 different wids in the search-order.
There's no checking for duplicate wids - it's perfectly permissible (although pointless) to have the same wid appear twice. It means the same wordlist will be searched twice, but obviously if said wordlist contains the word of interest, its first incarnation will find a match and its second incarnation will not be invoked. If the word does not exist, the wordlist will be searched twice, the second being fruitless. It does mean I can make this example though:

GET-CURRENT, SET-CURRENT, and DEFINITIONSThe words we've discussed so far cover searching for words: how do we control which word-list receives a new word definition?
In ANS Forths this is called the "compilation word-list" or sometimes "current word-list", and you can get it with GET-CURRENT (which returns a wid to TOS) or set it with SET-CURRENT which takes a wid from TOS.

DEFINITIONS is equivalent to setting SET-CURRENT to whichever wid is currently first in the search-order: new definitions go to the first dictionary in the currently active set of dictionaries.
ONLYONLY is the sole-surviving refugee from the old Forth Vocabulary era: its purpose is to set the search-order back to the default state: that is, a single wid which is that of FORTH-WORDLIST and a search depth of 1.
Note that this doesn't change the setting of the "compilation word-list", so you may want to combine ONLY with DEFINITIONS for a true reset.

antforth
Epic 12 is all about vocabularies and search orders. Up to now, all AntForth words (be they system defined or user defined) have lived in a single dictionary. You may recall that the dictionary is a simple hash-based mechanism that manages 64 separate lists of words: the hashing function balances
Epic 11 will give us exception handling - the ANS Forth "EXCEPTION" word-set and also the ANS Forth "EXCEPTION-EXT" word-set - although the user should note that as of 2017 "EXCEPTION-EXT" is no longer optional and now considered mandatory, so "EXCEPTION-EXT" is itself exceptional, it would seem.
AntForth is not shy! It includes both!
Note that ANS Forth's exception handling centres around the THROW, CATCH, ABORT and ABORT" words, which differ from many traditional implementations with words like ALERT, (ALERT), ESCAPE, EXCEPT etc.
Nothing too remarkable, just the usual cycle of /bmad-create-story, /mad-dev-story and /bmad-code-review finishing off with a retro /bmad-bmm-retrospective.
The CATCH word sets up an exception handling context: any exceptions that are thrown (by the THROW keyword) within the exception context are handled by the code that immediately follows it.
If the exception handling code immediately follows CATCH how do we tell it what code to execute in the exception context - i.e. the code we want to run that might throw ?
We use an execution token, which is Forth's equivalent of a pointer to a function. It's the address of a word's implementation. If you're thinking "that sounds like the Code Field Address of the word" then you'd be right (for an indirect threaded Forth like AntForth).
The usual approach is something like this:
: safe/ ( n1 n2 -- n1/n2 | 0 error-code )
['] / catch ; \ Executes / , catches error if n2 is 0
: test-division
10 0 safe/ ?dup IF
." Error occurred: " . cr \ Handles error (e.g., prints -50, etc.)
ELSE
." Result: " . cr \ Executes if no error
THEN ;
Let's see it in action:

We get the xt for the builtin / word and use CATCH to execute it. If that code throws an exception (for example, attempted division by zero) then whatever value was thrown goes in TOS execution immediately resumes with the word following CATCH. Otherwise if no exception was thrown (or if a 0 was explicitly thrown) CATCH pushes a zero onto TOS and continues.
?DUP duplicates TOS only if it is non-zero, so it's only going to DUP if an exception was thrown. If an exception was thrown, ?DUP duplicates the error code so that we can both test it with IF and print it out with .. But if an exception wasn't thrown TOS contains zero, so we don't ?DUP, IF consumes the 0 in TOS and the . in the ELSE clause prints the value of the division operation, which was underneath TOS.
Here's what it looks like when the exception isn't thrown:

THROW simply takes an error code from TOS and throws it:
: check-range ( n -- )
dup 10 > IF -99 throw THEN ; \ Throws -99 if n > 10
Note that that includes error code 0. The -1 error code is special, and performs the action of the ABORT word (more details below). Similarly -2 is special, and performs the action of the ABORT" ("abort-quote") word, more details below.
Exception handlers nest, so it is quite acceptable to handle an exception, do some cleanup, and then re-throw the exception to a higher level exception handler:
: managed-word
['] risky-word catch
dup IF \ If an error happened
." Cleaning up..." cr
throw \ Rethrow the original error
THEN ;
ABORT (or -1 throw) immediately quits execution, and, crucially clears the data and return stacks.
: PROCESS-DATA ( n -- )
DUP 100 > IF
DROP
CR ." Value too high, aborting."
ABORT
THEN
. ;
50 PROCESS-DATA \ Output: 50 ok
150 PROCESS-DATA \ Output: Value too high, aborting. ok
ABORT" is a compile-time word that parses a string (ending with a "). At run-time, it removes a value from TOS and if it's not zero it displays the string that was defined, then follows up with the usual ABORT sequence (clearing the stacks).
Here's a complete list of AntForth's error codes. Values between -1 and -255 are reserved for the ANS Forth standard assignments 9.3.4.
Values between -4095 and -256 are reserved for use by AntForth itself.
Positive non-zero values are available for custom user error codes.
| Error code | Where | Meaning |
|---|---|---|
| -1 | Standard | ABORT |
| -2 | Standard | ABORT" |
| -3 | Standard | Stack overflow |
| -4 | Standard | Stack underflow |
| -5 | Standard | Return Stack overflow |
| -6 | Standard | Return Stack underflow |
| -7 | Standard | DO loops nested too deep |
| -8 | Standard | Dictionary overflow |
| -9 | Standard | Invalid memory address |
| -10 | Standard | Division by zero |
| -11 | Standard | Result out of range |
| -12 | Standard | Argument type mismatch |
| -13 | Standard | Undefined word |
| -14 | Standard | Interpreting a compile-only word |
| -15 | Standard | Invalid FORGET |
| -16 | Standard | Attempt to use zero-length string as a name |
| -17 | Standard | Pictured numeric output string overflow |
| -18 | Standard | Parsed string overflow |
| -19 | Standard | Definition name too long |
| -20 | Standard | Write to a read-only location |
| -21 | Standard | Unsupported operation |
| -22 | Standard | Control structure mismatch |
| -23 | Standard | Address alignment exception |
| -24 | Standard | Invalid numeric argument |
| -25 | Standard | Return stack imbalance |
| -26 | Standard | Loop parameters unavailable |
| -27 | Standard | Invalid recursion |
| -28 | Standard | User interrupt |
| -29 | Standard | Compiler nesting |
| -30 | Standard | Obsolescent feature |
| -31 | Standard | >BODY used on non-CREATE definition |
| -32 | Standard | Invalid name argument |
| -47 | Standard | Compilation word list deleted |
| -48 | Standard | Invalid postpone |
| -49 | Standard | Search order overflow |
| -50 | Standard | Search order underflow |
| -51 | Standard | Compilation word list changed |
| -52 | Standard | Control flow stack overflow |
| -53 | Standard | Exception stack overflow |
| -56 | Standard | QUIT |
| -57 | Standard | Exception sending/receiving char |
| -58 | Standard | [IF], [ELSE] or [THEN] exception |
| -258 | System | Bad operand (asm) |
| -259 | System | Nested CODE (asm) |
| -260 | System | CODE needs name (asm) |
| -261 | System | END-CODE without CODE (asm) |
| -262 | System | LABEL must precede opcodes (asm) |
| -263 | System | JR out of range (asm) |
| -264 | System | Too many labels (asm) |
| -265 | System | Too many fixups (asm) |
| -266 | System | EQU outside CODE (asm) |
| -267 | System | Bare integer (asm) |
| -268 | System | Unresolved label (asm) |
| -269 | System | Already fixed (asm) |
| -270 | System | Not in CODE (asm) |
| -271 | System | Bad Displacement range (asm) |
| -272 | System | Bad bit range (asm) |
Continue reading: Multi-vocabulary search order
antforth
Epic 11 will give us exception handling - the ANS Forth "EXCEPTION" word-set and also the ANS Forth "EXCEPTION-EXT" word-set - although the user should note that as of 2017 "EXCEPTION-EXT" is no longer optional and now considered mandatory, so "EXCEPTION-EXT" is
Epic 10 is all about mopping up the remaining bits of missing functionality that stop us claiming 100% compliance with the ANS Forth Core wordset.
The main omissions are:
EVALUATE and ENVIRONMENT? and a couple of other stragglersOnce pictured numbers are available, we can re-implement all the current baked-in number formatting routines (., .S etc) on top of it: so there shouldn't be an appreciable increase in overall code size.
Nothing too remarkable, just the usual cycle of /bmad-create-story, /mad-dev-story and /bmad-code-review finishing off with a retro /bmad-bmm-retrospective.
Here are some basic examples of double precision integers in action:

Note that the . denotes a double-precision integer and has nothing to do with "floating point" or the decimal point. This is a classic Forth trap for the unwary. Witness:

Here are a few more related words that were created in this sprint:

We also get a set of useful multiply routines:

M* takes two signed single precision values and multiplies them together to give a double precision signed result. UM* takes two unsigned single precision values and multiplies them to give a double precision unsigned result. Finally D* takes two signed double precision values and multiplies them to give a double precision signed result: watch for overflow with this one!
Finally in this section we have some useful division routines:

Why so many? Well, UM/MOD is an easy one, it divides an unsigned double by an unsigned single and returns an unsigned qotient and an unsigned remainder.
Once we introduce signedness, things get interesting. SM/REM is symmetric or truncating division, which rounds towards zero (truncating the decimal part) and the remainder has the same sign as the dividend (the number that is being divided).
The "symmetric" description comes from the fact that -7 / 3 and 7 / -3 yield the same magnitude quotient with opposite signs.
This is the standard / operator in C, C++, Java, JavaScript etc.
FM/MOD gives you floored division of a signed double by a signed single. This rounds down to the nearest integer in the direction of negative infinity. The remainder always takes the sign of the divisor. You might be familiar with this sort of division from Python's // operator.
Pictured numeric output is Forth's traditional technique for displaying formatted numeric output. It operates on double precision signed integers, and builds the ASCII representation digit-by-digit from right to left.
The important things to remember:
You start a conversion with <#, then you get the least significant digit with #. Then the next significant digit with #, and so on. At any point you can say #s which means "as many #s as are left to finish converting the number". You can use HOLD to insert other characters at appropriate junctures.

You can use SIGN to print a negative sign if needed, although it's behaviour is a little weird: it takes a (single precision signed) integer from the top-of-stack and it it is negative it prints a minus sign. So you need to have this value on the stack before you start number conversion, and you need to prefix your SIGN word with a ROT word, like so:

You may be thinking "hold on, what's that ROT in there for?!" - I certainly did. The answer is, at each # step the interpreter is doing ( ud-lo ud-hi -- quot-lo quot-hi ) so there's always a working quotient on the stack until we emit #> - so at the point that we emit SIGN (which must come before #>) the top three entries on the stack are -1 0 0 - our original -1 and two cells for the remaining double precision quotient (which is zero). So the ROT brings the -1 to the top-of-stack so SIGN can consume it.
We can actually see this in action:

TUCK DABS ... ROT SIGN is a common idom in Forth to display a signed double precision number, and S>D TUCK DABS ... ROT is another for single precision signed numbers.

*/ takes three single-precision numbers on the stack (n1 n2 n3 -- n4) - it multiples n1 by n2 giving a douple precision result, then divides that by n3 to give the quotient n4.
*/MOD is similar but it also returns the remainder (n1 n2 n3 -- n4 n5).

EVALUATE evaluates Forth code from a string exactly as if you had tyed it in. It's the equivalent to eval() from other languages.

The ENVIRONMENT word lets you query certain environmental constants in the system. There are a number of different queries you can make, each identified by name. The name precedes the ENVIRONMENT call and is in the usual Forth (c-addr u --) (start address, count) convention. You'll get back a true or a false in TOS to indicate whether ENVIRONMENT? knows what you are talking about: a TRUE value is accompanied by other pertinent information in the rest of the stack.
Here are the queries AntForth supports:
| Name | Type | Constant? | Meaning |
|---|---|---|---|
/COUNTED-STRING |
n | yes | max size of a counted string, in chars |
/HOLD |
n | yes | Size of pictured numeric output string in chars |
/PAD |
n | yes | size of scratch area pointed to by PAD |
ADDRESS-UNIT-BITS |
n | yes | size of one address unit, in bits |
FLOORED |
flag | yes | true if floored division is the default |
MAX-CHAR |
u | yes | maximum value of any character |
MAX-D |
d | yes | largest usable signed double integer |
MAX-N |
n | yes | largest usable signed integer |
MAX-U |
u | yes | largest usable unsigned integer |
MAX-UD |
ud | yes | largest usable unsigned double integer |
RETURN-STACK-CELLS |
n | yes | maximum size of the return stack, in cells |
STACK-CELLS |
n | yes | maximum size of the data stack, in cells |
AntForth also supports wordset queries from the 1994 ANS Forth standard, although the user should note that the 2012 standard makes these as obsolete:
| Name | Type | Constant? | Meaning |
|---|---|---|---|
/CORE |
flag | no | True if complete Core wordset is present |
/CORE-EXT |
flag | no | True if Core extensions wordset is present |
Other values may become available as AntForth supports them.
Continue reading: Exception handling and internal error migration
antforth
Epic 10 is all about mopping up the remaining bits of missing functionality that stop us claiming 100% compliance with the ANS Forth Core wordset. The main omissions are: * double-precision integers and their operators * the traditional "pictured numeric output" system for printing formatted numbers * EVALUATE and ENVIRONMENT? and
Epic 9 is all about making numeric literals in AntForth more pleasant to use.
Currently, when you type 42 then decimal 42 goes onto the stack, and if you actually wanted 0x42 hexadecimal then you'd write HEX 42 DECIMAL .. Both HEX and DECIMAL change the radix that's used for numbers, and it affects both the "input" and output of numbers, which is oft-times inconvenient.

This sprint add code that lets us prefix a hex number with $ or 0x, a binary number with %, a decimal number with #, and it also understands character literals by putting single characters inside single quotes like this 'A'.
So now we can change our "input" base on the fly without mucking about with HEX or DECIMAL or BASE.
Here are some examples, running on the MicroBeast:

Mid way through this sprint I had a change of heart regarding the whole phase 2 goal of moving our inline assembler out into a separate ASSEMBLER.FTH file that would be loaded on demand.
The original idea was to get the binary down as small as possible so that AntForth might be a contender for inclusion in the MicroBeast's boot ROM (where space is at a premium).
I reversed this decision for two reasons:
CODE word. So that means ASSEMBLER.FTH would need to be in the boot ROM anyway, and I have a feeling that it will be significantly larger than the (native z80) code it is replacing.To pull off a course correction like this we simply /bmad-bmm-correct-course and explain ourselves. The LLM will rewrite all the planning documents accordingly. It seems a little miffed that I have effectively nixed its "north star" demo (it's obsessed with north stars), and it would have been cool, but just loading and saving Forth files from/to disk will do me.
...or is it?
While preparing the above screenshots, I noticed a "bug". HEX and DECIMAL are meant to change the value of BASE. But when I printed it out it always came back as 10.

HEXchanges the radix to 16,BASEputs the address of the BASE variable on to the stack so that@("fetch") can read it, and.prints the top value of the stack.
I actually got as far as loading up GForth to screenshot a counter-example before the penny dropped.
As I said at the start, HEX and DECIMAL change BASE both for input and output of numbers, and 16 in hex is, of course 0x10...
Still not convinced? Perhaps this will make it clearer:

Here we create a variable old_base, switch to HEX, store a copy of BASE in old_base, switch back to DECMINAL, and finally fetch and print out old_base in the current number base, which is of course decimal.
Continue reading: Core compliance gaps
antforth
Epic 9 is all about making numeric literals in AntForth more pleasant to use. Currently, when you type 42 then decimal 42 goes onto the stack, and if you actually wanted 0x42 hexadecimal then you'd write HEX 42 DECIMAL .. Both HEX and DECIMAL change the radix that'

With an entire epic of refactoring under our belt, AntForth is lean and mean! It's time to focus on adding some new functionality. Here's what I want to add in Phase 2:
$ and binary numeric literals with %, so that we can input numbers in those bases without changing BASE, which is tedious. These prefixes aren't in the ANS Forth standard, but they are in the ANS Forth 2012 proposal, so this seems like a good way to go. I'm also adding a 0x prefix for hex, because I find that more convenient.In the interests of keeping the interpreter small, we will leverage two key new abilities (multiple voacbularies, CP/M file support) to move the built-in assembler out to an external ASSEMBLER.FTH file that will be automatically loaded whenever the CODE word is invoked. Since the assembler is the single largest builtin component currently, this should provide plenty of space for new features, without losing any functionality. Having to have a separate ASSEMBLER.FTH file accessible is a minor inconvenience, but hopefully it's worth it. I hope it is joined in the future by other libraries and optional components.
I used the retro at the end of the refactoring epic as an opportunity to discuss with the team the broad strokes of what I wanted to achieve in the next phase.
As far as BMAD is concerned, MVP is done so the existing PRD and architecture documents are defunct - we need to create new ones. We do this in the familiar way, bmad-create-prd followed by bmad-create-architecture. BMAD is smart enough to realise what's happening for both of these, and offers to archive the old documents before creating fresh ones.
We follow this up with a /bmad-bmm-create-epics-and-stories and mid-way (after it has created the epics but before it dives into the stories) I call a "party mode" discussion to check for any howlers.
While developing the stories for aprint 12 it became clear that the LLM was weaseling out of porting all the current assembler functionality to a separate Forth file and planned to leave anything tricky behind - so I nipped that in the bud.
Finally /bmad-bmm-sprint-planning gets our sprint-status.yml all ship-shape, and we're finally ready to begin new development.
Continue reading: Number prefixes
antforth
With an entire epic of refactoring under our belt, AntForth is lean and mean! It's time to focus on adding some new functionality. Here's what I want to add in Phase 2: * Add the ability to prefix hex numeric literals with $ and binary numeric literals with
MVP is done! Before starting to think about additional phases and new features, I wanted to do a bit of optimisation for code size, in part prompted by the tentative offer of putting AntForth into the MicroBeast's romdisk - where space is at a premium.
When we made our first release (before we added the inline assembler) our antforth.com binary weighed in at 6.82 KB, and when we'd finished the assembler (with 100% z80 opcode coverage) we were up to 15.2 KB. That's nearly 8.5 KB on the assembler alone, which seems like a lot.
The smallest z80 assembler I can find is Kroc's v80, which is a table driven assembler that weighs in at 6.7 KB according to the author. I'd give you a link, but he's deleted the entire repo as an act of anti-AI defiance. Oh dear. (Ironic that I only discovered v80 through the AI search thingy at the top of google search).
But if his post is to be believed, AntForth is 1.8 KB off the pace.
I'd noticed that there was a lot of duplication in outputting console messages, with a lot of places in the code setting up their own BDOS calls. In addition it seemed like there was a lot of duplication, particularly around stacking registers, that might benefit from factoring out the common functionality (which is a very Forth thing to do!).
I assembled the LLM team and set them to finding opportunities to reduce the code size. We created a new epic 6 just for optimisation, covering the stdout type stuff I already mentioned, and various 'peephole' optimisations around refactoring code. They came up with an ingenious LD, rework on their own.
At every story in this sprint the assembler remained fully functional whilst gradually reducing in size. No regressions at all.
While doing the retro at the end of sprint 6, it occured to me that much of the complexity that remained in LD, (and in many other words) was to do with stacking registers while we juggled them for other purposes - something that the alternate register set would be ideal for, and which we hitherto had not used at all.
Cue epic 7 (only 3 stories this time) for shadow register optimisations.
During the retro for epic 7, a few more optimisations were identified, including that we hadn't taken advantage of EX AF, AF' either, so another epic (8) was created, with 4 short stories this time around.
After 14 sprints-worth of refactoring, the final binary size is:
** 13.7 KB **
That means our assembler is 6.9 KB, so we're roughly 200 bytes larger than the most compact assembler on record - 1.5 KB smaller than the original implementation!
Continue reading: Phase 2 planning
antforth
MVP is done! Before starting to think about additional phases and new features, I wanted to do a bit of optimisation for code size, in part prompted by the tentative offer of putting AntForth into the MicroBeast's romdisk - where space is at a premium. When we made
The last story in the current epic is a review of our compliance to the ANS Forth "Core" wordset.
The result: 72.2% compliance
Some of this is deliberate omission: I chose to exclude double precision cells and Forth's quirky number formatting system from MVP.
Others are a little bit niche (ENVIRONMENT?) or just a bit complicated to implement for the value that they add (EVALUATE).
However, there are a core group of about 15 words that I would consider pretty much essential, that we're not implementing. Words like EXIT, 1+, 1-, 2*, 2/, CELL+, ?DUP, CHAR, ' ("tick"), >BODY, ABORT", ['], [CHAR], CHAR+ and CHARS.
These are mostly quick wins, so I had Bob the sprint manager persona add a new story to implement them before the end of the MVP, and then I asked him to run his compliance report again.
New result: 83.5% !
Not bad. You can view the report on github - go to the previous commit to see the "before" report.
All unit, REPL and interactive tests are passing. In manual testing everything seems to be working as expected:

Continue reading: Optimising for code size
antforth
The last story in the current epic is a review of our compliance to the ANS Forth "Core" wordset. The result: 72.2% compliance Some of this is deliberate omission: I chose to exclude double precision cells and Forth's quirky number formatting system from MVP. Others
This sprint brings us the MARKER word which lets us undo word definitions. The way it works is, you type MARKER foo and then go about your business, defining words and variables etc. At some future point you can type foo and it will restore the dictionary to the state it was in before foo was created.
After foo is called, foo ceases to exist, and so do any words that were created after it.
This is where MARKER is defined:

MARKER creates a new word whose Code Field is JP DOMARKER. It stashes the current hash table state in the new word's parameter field, then adjusts the bucket that the new word is linked from to the state it was in before the new word was created, so that when the new word is called, it removes itself, and anything that was defined subsequently.
Here's the definition of DOMARKER (a helper, not a word) that is the runtime behaviour of any words created with MARKER. It basically just restores HERE and the hash bucket lists from the words parameter body.

Also note that you can have multiple markers, which gives you multiple restore points:

Continue reading: MVP!
antforth
This sprint brings us the MARKER word which lets us undo word definitions. The way it works is, you type MARKER foo and then go about your business, defining words and variables etc. At some future point you can type foo and it will restore the dictionary to the state