Look at that smooth fade in:
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
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.