Cactus

Shocking Finale

30 October 2023 (programming retrochallenge retrochallenge2023 retro homelab)

Sunday, 15th October. Day of the HomeLab-2 game jam deadline. My port of The Revenge, or at least its first half, is fully playable. It even has quicksaving/loading implemented, with an in-memory buffer. Unfortunately, I didn't have time to find out what it would entail to implement persistent saving on cassette, but at least with quicksaving you can play cautiously enough to avoid frustrating deaths.

But can you actually win the game? To make sure you can, I wanted to play through from start to finish, using the hacked-up game scripts I ended up with that has a lot of the locations and scripts stripped. I could have just played the whole thing on a HomeLab-2 emulator, but I wanted something a bit more streamlined for two reasons:

So I ended up writing a no-frills bytecode interpreter as a normal desktop Haskell program. By no-frills, I mean that quality-of-life builtin commands like INVENTORY or SAVE were unimplemented. But of course all the command handlers that were implemented via script were available. And on top of that, the big advantage of this interpreter was that it supports playing with an external input transcript file: the transcript's lines are executed at startup, then the game drops into interactive mode, with all subsequent player commands appended to the transcript. This way, if I find something fishy, I can fix the script files, and re-run from the game's beginning up to that point and then go on playing the fixed version.

Technically, everything worked without issues. However, game-design-wise, I did find two faults that needed fixing. Both of them involved the game depending on its graphics to guide the player:

Writing this new interpreter, finding and fixing these mistakes, and then getting all the way to the game's finale took most of the free time I had that day since, of course, this wasn't the only thing I've spent Sunday on. But hey, it all came together just in time.

Except, when I took the finalized version and converted it into WAV for submission, the resulting audio file didn't load correctly on my emulator. It seemed to get to the start of the game, but then it immediately crashed, seemingly leaving the machine in a weird state where the screen would keep flashing between blank and the correct start screen.

I was tearing my hair out at this point. I'VE WORKED ON THIS ALL THIS TIME, MADE IT ALL WORK, PUT A REASONABLE VERSION OF A 64 KB GAME INTO THIS 16 KB MACHINE, AND NOW THE WHOLE THING JUST CRASHES FOR NO REASON WHEN I WANT TO SUBMIT IT, MERE HOURS BEFORE THE DEADLINE?!?!

AAAAAAARGH!

But, you might ask, how was this different from running the game in the same emulator, with no issues, all week long during development?

First of all, during development, I didn't load my game using an emulated cassette. I didn't load any of my games (Snake or HL-2048) that way. Instead, I hacked my emulator so that it initialized RAM contents with my game, starting at the right address. Then you can just type something like CALL 17000 (using your program's start address) into BASIC and your pgoram starts.

This was to save time: even something as simple as Snake is takes than half a minute to load from tape, and due to how crunched I was for time, I never implemented any flags for temporary faster-than-real-time emulation. For The Revenge, the 16 KB game takes almost three and a half minutes to load. This doesn't sound like a lot, but imagine if you had to develop something where it takes three and a half minutes to try anything out.

OK, so the dev environment didn't match prod, a tale as old as time. But what is causing the crash when loading from tape? And why was there absolutely no similar problem when submitting Snake and HL-2048? To understand that, I need to talk a bit about HomeLab-2's cassette format.

As I have mentioned in an earlier post, bits are stored on tape as 1.6 ms long patterns. Unsurprisingly, eight bits, one after the other, make up one byte. But what do the bytes mean?

The numbers, Mason!

A file on cassette is made up of one or more records. Apart from irrelevant details like the file name and some 0-byte padding used by the firmware for synchronization, each record consists of a starting address, and then a sequence of bytes that will be loaded to RAM from that address. If you assembled a program that starts at, let's say, address 0x4200, you might think it's enough to have a single record with that start address. But then, after loading, seemingly nothing would happen: the user would have to somehow know that they have to type CALL 16896 to actually start the program.

For example, on a Commodore 64, the standard way of solving this is to include a BASIC starter: a one-liner BASIC program that only consists of 10 SYS 16896 (SYS being C-64 BASIC's equivalent of CALL). BASIC programs have a known start location, so you would put that program at that location, and then after loading, the user can just type RUN to start the BASIC program which, in turn, starts the machine-code program.

If you wanted to do the same on the HomeLab-2, you could either have two records, one consisting of the one-line BASIC starter and the second containing the meat of your program; or you could assemble the machine-code program right after the BASIC program and put everything into one record. The problem with either approach is that the in-memory representation of BASIC programs on the HomeLab-2 is quite fragile. When you save a BASIC program to tape using the built-in BASIC SAVE instruction, what you get is a record that loads to the very start of RAM, and then contains 256 bytes of internal firmware state until it gets to the actual first byte of the program itself. So you don't just save your program – you save your whole firmware state. And that is because that firmware state includes the pointers to the first and last lines of the BASIC program.

This makes it pretty much impossible to sanely generate a tape image from scratch. The only easy option is to boot up the machine, type in your BASIC loader program, and then save that as a "template" to prepend to your machine-code program. But even with this approach I was unable to get it working reliably when, since it requires all the memory it can get, the machine-code program needs to start right after the BASIC program.

In fact, for The Revenge, I couldn't even spare the space for the BASIC starter program. But even for Snake and HL-2048, I wanted to generate everything from scratch instead of fiddling around with this 256 extra bytes of who-​knows-​how-​redundantly-​intertwined firmware state.

There's a dirty trick to avoiding the BASIC starter altogether; in fact, it's even better since it results in an auto-starter, i.e. a program that automagically starts after loading, not even RUN required. The trick hinges on the fact that after a program is normally loaded, the LOAD command returns with the usual OK BASIC prompt and waits for the user to type in their next command.

However, waiting for the user to type in a command involves the firmware's input routine at 0x28 which in turns calls through an indirection at 0x4002, i.e. in RAM! Which means we can hijack that by using a two-byte-long record that starts at 0x4002 and contains our program's real start address. A second record then contains the main program with no BASIC starter. After the full program is loaded, BASIC calls 0x28 which in turn jumps to the adress stored in 0x4002 which jumps to our program.

And so after what felt like an hour of desperation, I put all of the above together in my head and then figured out the source of my problem. Snake and HL-2048 has very simple directional input and a game loop that consists of checking the relevant keys' state, reacting to that and then redrawing the screen. The Revenge, instead, uses textual input, and the easiest way of doing that was to outsource the decoding of keypresses into ASCII character codes to the firmware.

Putting it like this, you can probably see already where this is going. I was actually using the 0x28 routine in my program... except, when loading from tape, that routine's vector was overwritten with my program's start address. So the game would start normally, print the first room's description, then start waiting for user input, which would effectively reset the game. Which is why the game was blinking, since one of the very first things it does is to clear the screen...

And of course if you're not loading from cassette, but instead inject the program directly into RAM, the input vector is not changed since the firmware's whole 0x4000..0x40ff area is left alone. Which explains why it was only the final "mastered" WAV file that was showing this issue, not the intermediate development builds.

Of course, this is the classic kind of bug where 99% of the effort is in figuring out what's happening, and then the fix is trivial. So the first thing my program does now is restoring the address at 0x4002 to the hard-coded value 0x0306, which is its value at boot as determined by using the built-in monitor.

So here it is, the obligatory screenshot:

The Revenge on the HomeLab-2

Yeah, not much of a looker. But it's got it where it counts: in content.

Oh, this screenshot reminds me of one more last-minute hack I did, but one that was planned all along, I just kept postponing this: since the game always starts printing response messages on a new line, we can ahead-of-time statically word-wrap everything. This requires absolutely no extra space at runtime, since we're just replacing some spaces with newlines, or even removing a space when a word naturally ends at the last column.

And this concludes my RetroChallenge series of posts for 2023. If you've read along, by now we've learned about the HomeLab-2, wrote some simple games, and then remixed a 35-year-old Hungarian text adventure game to make it fit into the 16 KB of RAM available on this strange, weird, but somehow still charming machine.

And as for the actual game jam? The Revenge came in 4th out of 13 entries. Interestingly, in the week since the official voting period ended, it's been steadily gaining extra votes from players and by now it's tied for second place. Maybe it took people some time to play enough of it to discover how much depth there is to it.


 
All posts
 There and Back Again »