Bun Runner Devlog -- 2025-01-03

January 3, 2025

Look at that smooth fade in:

Bun runner game map and sprite fade in from black

The palette manager I talked about on 2024-12-22 is finally plugged in at the right place to allow the game to fade in the palette from black (or, really, any color) when the level starts up.

Interfaces, implementations, and dependencies

As I’m writing unit tests for all this, it’s required me to really think a lot about how to structure everything. Originally, I had mashed together a lot of the implementation details into what I call the Game Screen, the main display of the game. This includes:

  • double buffering
  • palette color definitions
  • the Copperlist definition
  • an implementation of “wait for bottom of frame”
  • RastPort building for the lower part of the screen for using AmigaOS graphic routines like text drawing

This is a very shallow interface! And these also bring in a lot of dependencies. This makes unit testing difficult, and would make future reuse challenging as well. I ended up splitting out some of the Copperlist handling and the palette definitions to separate modules. The palettes are currently hardcoded via INCBIN, but they’ll soon enough be loaded dynamically for each level, so they need to be in a separate place anyway.

This allowed me to write some very specific tests for the following:

  • ensuring a palette fade structure could be built for all three palettes: the foreground, background, and sprite palettes. This structure is essentially a multidimensional array built to be easily iterated in assembler.
  • ensuring that I can pick an index in that multidimensional array and set the game screen Copperlist colors from that array data correctly
  • walking through the “level starting up” logic, which includes:
    • prepping a bunch of state and setting default copperlist colors
    • pausing for specific numbers of frames in between palette changes or when about to start the game

Mocking Amiga system libraries in unit tests

Anthropomorphic female squirrel in front of a bulleting board with cards with numbers

Testing that the pauses were working correctly was an additional challenge. I actually built out the ability to mock Library Vector Offset (LVO) routines in libraries so that I can call WaitTOF() directly in the code and not have to deal with:

  • some hacky solution in the assembler code to allow mocking
  • the inability to spy on real library function calls w/o patching the library itself
  • undesired side effects in unit tests like having real frame delays

I covered Library Vector Offsets a little bit in my video about revisiting the AMOS Pro BSDSocket code I wrote 20+ years ago, but never went into precise detail as to what they are. Essentially, they are a list of JMP instructions that live at negative offsets from the library’s base that you get from OpenLibrary(). The stuff actually at the library base varies – for the Graphics library, for example, it includes links to interrupts, fonts, and other data you’d want for OS-compliant graphics routines.

Using an LVO in assembler looks like this. There may be some sugar around the JSR call and how you get the LVO, but it all turns into this eventually:

_LVOWaitTOF EQU -270

  MOVE.L _GfxBase,A6
  JSR _LVOWaitTOF(A6)
  ; the next instruction

A6 is the address register that AmigaOS always uses for LVO-based calls. There’s no technical reason, just convention. JSR is “Jump to Subroutine”, which pushes the memory location of the next instruction onto the stack and changes the code executation location to the provided address. In this case, that address is “whatever is in A6 minus 270”. JSR expects that you’ll eventually RTS, or “Return from Subroutine”, which involves changing the code execution location to the value that’s popped off the stack.

What’s at “whatever is in A6 minus 270”? It’s this:

  JMP <place where the code actually lives>

JMP is “Jump to Location” and it doesn’t expect that you RTS. However, the code that you do jump to does RTS, which means you’ll end up back where you left off in you own code anyway.

LVOs are always 6 bytes away from each other. Why six bytes? Because that’s how many bytes it takes to hold a JMP instruction:

4EF9 FFFF FFFF ; 4EF9 == JMP, FFFF FFFF == 32-bit (4 byte) memory address

Check out the chart here for more opcode details: https://info.sonicretro.org/SCHG:68000_ASM-to-Hex_Code_Reference

This approach means you can change the contents of a library function to your heart’s content and not have to worry about updating function calls when you release a new version. The address of the JSR may change, but the LVO won’t (and can’t!).

Library bases in C and Assembler

_GfxBase is another Amiga library convention. C compilers like SAS/C can automatically open and close libraries for you, if they have the right information. When they do this, they normally place the address of the base of the library in a global variable. The name of that global variable comes from the library’s fd file, in the ##base field. That global variable is then used to JSR to the correct LVO and put the function’s parameter values in the correct registers. Look up pragma libcall in the SAS/C Development System Library Reference for details on how that works.

On the C side, if you do nothing but use Graphics library routines, you get that global _GfxBase variable (seen as GfxBase-no-underscore in C because of how variable and functions are made public in C code). With the right included file setup, your assembler routines that use _GfxBase can use either ones your assembler programs open, say in a system.asm file that opens and closes libraries and has that public variable, or the one that just happens to come along with your C unit tests.

So to get mocked Amiga library functions, I:

  • create a block of memory big enough to cover the size of the largest LVO call I’m going to make
  • place a JMP <address> instruction at each point from the end for each LVO, with the address being the pointer to a mock function in my C test suite
  • set the library base to the end of that memory block
  • call the library like I normally would in assembler or C

This means that my assembler code under test operates as expected, but now the calls to WaitTOF() are logged and I can treat them as you would any other mock in any other testing framework.