Exploring the mind-bending way that Revs is packed into memory
Games on the BBC Micro often contain relocation code, particularly those that load from floppy disc. Most of them have relatively simple relocation code, while others, like Elite, have more sophisticated loaders that really shake things up in the search for every spare byte.
Revs, however, takes relocation to a whole new level. Not only is the game binary a complicated 13-piece puzzle that unfolds itself into available memory like an elegant piece of origami, but the unpacked game code contains an even more intricate 43-piece puzzle that reassembles itself before each driving session and splits itself back up again afterwards, all while the game is running.
The way in which the game rearranges itself is completely mind-bending, but let's start with a quick introduction to relocation in general, before moving on to the intriguing jigsaw puzzles that are buried in the Revs binary.
Relocation on the BBC Micro
Relocation code typically runs right after the game's binary file has been loaded into memory. The canonical example on the BBC Micro involves moving the game code down the memory map before starting it up, as a way of coping with the memory constraints caused by the BBC's disc filing system (DFS).
This approach is particularly relevant to disc-based games because in a standard BBC Micro, the DFS steals a whole chunk of memory from the user. This is memory that a lot of games need for themselves, particularly those that use memory-hungry, multi-colour graphics modes. On the unexpanded BBC Micro, the beginning of user memory (which is accessible via the PAGE variable in BBC BASIC) is set at &0E00, but as soon as you add Acorn's DFS, it leaps up to &1900, a difference of 2816 bytes, or 2.75K. The extra memory is used by the DFS as workspace, and it's a hungry beast.
2.75K is a significant loss when you consider that the 32K of RAM in the standard BBC Micro gets shared between operating system workspaces, the 6502 stack and screen memory, meaning the current program has to squeeze into whatever's left over (screen mode 2, for example, takes up a whopping 20K, leaving very little spare room for user programs). Later BBC machines, like the BBC Master, solve this issue by paging in extra memory for the filing system workspace so they can leave PAGE alone when switching to disc, but this isn't the case on the original BBC Micro, where disc users have to take a considerable memory hit compared to tape users.
It turns out that it's possible to lower PAGE to &1100 and still use most functions of the DFS, but if a game doesn't need to access the disc drive after it's finished loading, we can do a lot better than that. Specifically, we can load our game binary at &1100 or above, and when it's finished loading, we can issue a *TAPE command to switch off the DFS, and then relocate the game code down to &0E00 (or even down to &0B00, if we don't use things like exploded fonts or function key definitions).
Some games take this a step further. Elite is famous for using practically every available byte in the BBC Micro, so its relocation code is a bit more sophisticated, not only relocating the game code downwards as far as it can, but also storing data such as ship blueprints and tokenised text in various locations from &0300 upwards. See the deep dive on the Elite memory map in my Elite project for details.
Revs relocates its code in a similar manner to Elite, unfolding itself from its loading point at &1200 into lower memory, but while Elite keeps most of its main game code intact as one block that it shuffles down the memory map, Revs takes a rather more convoluted approach. Let's take a look at that now.
Unpacking the game binary after loading
Revs does have a loader program, called Revs1, that runs after the mode 7 loading screen gets drawn, but it mostly contains copy protection code. The version of Revs analysed on this site has had the protection code disabled, so for our purposes the loader does two very simple things: it *LOADs the track data file (Silvers) at address &70DB, and then *RUNs the main game binary (Revs) at address &1200.
The entry point for the main game binary is the Entry routine, at the start of the file (i.e. &1200). The first thing that Entry does is relocate the first 256 bytes of the game binary from &1200-&12FF to &7900-&79FF, and then it jumps to the code in its new location.
This relocated block contains the SwapCode and MoveCode routines. These two routines perform a fairly complicated code relocation process, before jumping into the main game code at SetupGame to start the game proper.
In all, this is what happens when the game binary loads:
- The *LOAD Silver command does the following:
- Load the Silverstone track data file at &70DB-&7813
- The *RUN Revs command does the following:
- Load the main binary file at &1200-&6FFF
- Jump to the Entry routine at &1200
- The Entry routine does the following:
- Move &1200-&12FF to &7900-&79FF
- Jump to the SwapCode routine that is now at &790E
- The SwapCode routine does the following:
- Swap memory between &70DB-&77FF and &5300-&5A24
- Run a checksum on the track data, and hang the machine if the checksums do not match
- Jump to the MoveCode routine
- The MoveCode routine does the following:
- Move &1500-&15DA to &7000-&70DA
- Move &1300-&14FF to &0B00-&0CFF
- Move &5A80-&645B to &0D00-&16DB
- Move &64D0-&6BFF to &5FD0-&63FF
- Zero &5A80-&5E3F
- Jump to the Protect routine, which jumps straight to the SetupGame routine (as protection is disabled)
To help visualise this process, the following table shows the memory structure of the game binary file and track data file just after they are loaded, but before any relocation code is run (i.e. when the game binary is at &1200 and the track data file is at &70DB).
I've given each block an ID, so in the next section you can see where they end up once the steps in the "relocation details" column are applied.
|1||&1200-&12FF||Moves to &7900-&79FF|
|2||&1300-&14FF||Moves to &0B00-&0CFF|
|3||&1500-&15DA||Moves to &7000-&70DA|
|4||&15DB-&16DB||Contains workspace noise that gets overwritten by MoveCode|
|6||&5300-&5A24||Swaps with track data at &70DB-&77FF|
|8||&5A80-&645B||Moves to &0D00-&16DB|
|9||&645C-&64CF||Contains zeroes that get overwritten by MoveCode|
|10||&64D0-&6BFF||Moves to &5FD0-&66FF|
|12||&70DB-&77FF||Swaps with game data at &5300-&5A24|
After the Entry, SwapCode and MoveCode relocation routines are run, we end up with the following in-memory layout. You can see from the IDs how mixed up the blocks are when compared to the original binary file.
|2||&0B00-&0CFF||Moved from &1300-&14FF in the game binary|
|8||&0D00-&16DB||Moved from &5A80-&645B in the game binary|
|12||&5300-&5A24||Swapped with &70DB-&7724 in the track binary|
|7||&5A25-&5FCF||Unchanged (except &5A80-&5E3F, which is zeroed by MoveCode)|
|10||&5FD0-&66FF||Moved from &64D0-&6BFF in the game binary|
|-||&6700-&6BFF||Unchanged (contains remnants of the moved block #10, which get ignored)|
|3||&7000-&70DA||Moved from &1500-&15DA in the game binary|
|6||&70DB-&77FF||Swapped with &5300-&5A24 in the game binary|
|1||&7900-&79FF||Moved from &1200-&12FF in the game binary|
Blocks #4 and #9 from the original binary file get overwritten during the relocation, and we also end up with a block at &6700-&6BFF that doesn't contain anything useful, just the remnants of block #10, which get ignored (this part of memory eventually gets overwritten by the game screen). This, though, is the memory map that finally lets us start the game, by jumping to the Protect routine at &63BD, and on to the SetupGame routine at &63E0.
The dashboard jigsaw
Complex though it is, this 13-piece binary file jigsaw puzzle is relatively straightforward compared to the relocation code that reorganises the game code on-the-fly while the game is running. This is a really interesting aspect of Revs; most games don't constantly reorganise themselves in this way... but then again, most games aren't this complex.
Buried within the game code are 43 further puzzle pieces, all individually spread throughout the codebase. Some of these pieces contain fragments of code, while others contain fragments of the dashboard image (and one piece contains fragments of both). I'm going to refer to these fragments as the "dash data", as they contain both dashboard and data.
Each piece of the dash data puzzle lives inside a block. All except for two of these blocks are spread evenly throughout the codebase, with block #0 starting at &3000 (dashData0), then block #1 at &3080 (dashData1), block #2 at &3100 (dashData2) and so on, with each block appearing every &80 bytes. Each block is 79 (&4F) bytes long, so the first byte in block #0 is at &3000 and the last byte is at &304F, while block #1 runs from &3080 to &30CF, and so on.
(The last two blocks, #41 and #42, don't follow this pattern - see the next section and the "Blocks #41 and #42" section below for more on this.)
Within each block is a fragment of data. This data is located at the end of the block, and each block can contain a different amount of data (though the amount of data in each specific block is always the same). For example, block #0 contains 52 bytes of data, and these bytes live at the end of the block, with the first data byte at &301C and the last data byte at &304F. Block #5, on the other hand, contains 77 bytes of data, with the block starting at &3280 and the data running from &3283 to &32CF.
No blocks are full to capacity; the biggest data blocks are 77 bytes, which leaves three spare bytes (as in the block #5 example we just looked at). The rest of the block can be used for anything; you'll often find text tokens in there, and sometimes even entire subroutines. For example, block #5 contains text token 19 at the start of its block, which takes up the three spare bytes.
Here's a list of all the dash data blocks in the game code, and their various addresses (we'll talk about the screen address in the next section). The links will take you to the data address in that block, though the source code just reserves the memory at this stage).
|Name||Type||Block address||Data address||Screen address||# bytes|
So to summarise, we have a jigsaw of 43 pieces, with 41 of those pieces scattered around the game code in regularly spaced blocks, and each of them stored as data at the end of each block. Some blocks contain fragments of the dashboard image, while others contain code.
But what's it all for? To answer that, we need to look at how the dashboard jigsaw fits back together.
Reassembling the dashboard jigsaw
Revs starts up in mode 7, which is a very frugal screen mode, taking up just 1K of screen memory from &7C00 to &7FFF. It's perfect for all the menus and driver tables, as it displays beautifully clear text using minimal resources.
Mode 7, though, is clearly unsuitable for a sophisticated driving simulator, so once we have chosen our options and entered our wing settings, the game switches screen mode to a custom graphics mode, which is explained in the deep dive on hidden secrets of the custom screen mode. For the purposes of this article, screen memory changes from the &7C00-&7FFF of mode 7, to the &5A80-&7AFF memory range of the custom screen mode.
You will notice that the memory range for the custom screen mode overlaps quite a large chunk of the memory map that we got after relocating the game binary with MoveCode. This is no coincidence; block #11 in the game binary, for example, loads at address &6C00 and stays there throughout the binary file relocation process that we discussed above. This block contains the bitmap for the top three-quarters of the dashboard, so when we switch to the custom screen mode, most of the dashboard is already there.
Similarly, part of block #6 in the game binary contains another fragment of the dashboard, so when this is swapped with the track data in SwapData, a little bit more of the dashboard is put in the right place. These two fragments are shown above as dash data blocks #41 and #42, which is why they don't fit into our regular dash data block system above - they have already been moved into screen memory by the end of the loading process.
The rest of the dashboard image, and a whole chunk of code, is split into the other 41 dash data blocks shown above. Just before the game switches from mode 7 to the custom screen mode, Revs calls the CopyDashData routine. This copies these pieces of our jigsaw puzzle into screen memory, which has the effect of displaying the rest of the dashboard on-screen, as well as poking a whole bunch of assembled machine code into memory beyond the end of screen memory, at &7B00-&7FFF.
The table in the previous section shows the destination address in screen memory for each fragment of dash data, and if you look closely, you can see there's an added twist to this mind-bending puzzle. Dash data blocks are inserted into screen memory in reverse order, so the first block that's inserted into the dashboard is block #40, then block #39, then block #38 and so on, until the end of the dashboard image is poked into screen memory as part of block #25. This block also contains a fragment of code at &7B00 which is continued in block #24, then block #23, then block #22 and so on, until we poke block #0 into the end of user memory to reach &7FFF.
The point of all this reassembly is two-fold. First, moving all the dash data out of the main game code leaves lots of empty memory blocks that can then be used during the driving phase. They end up storing a screen buffer for the track view, which gets drawn into screen memory and fitted around the dashboard and wing mirrors - see the deep dive on drawing around the dashboard for more details.
Second, the jigsaw approach enables us to switch screen modes without worrying about screen memory overwriting anything, as before we switch modes, we can pack up the dashboard and screen memory code and safely stow it back in the body of the main game code, and only then switch to a screen mode that can reuse the memory that previously contained part of the dashboard and our relocated code.
This reassembly process and its reverse are implemented by the CopyDashData routine, so let's take a look at how this routine works in more detail.
The CopyDashData routine
The CopyDashData routine uses a whole bunch of tables and variables to implement the complicated moving and reassembly process above. Not only that, but it also supports moving the content in the opposite direction, from screen memory back into the main game code. We need to do the latter when we want to switch back to mode 7 after the race is finished, otherwise mode 7's screen memory will overwrite parts of the dash data.
Specifically, the CopyDashData routine copies data from the main game code into screen memory if bit 7 of A is clear. If, on the other hand, bit 7 of A is set, then the routine copies the data back from screen memory into the main game code, splitting the data up into each of the dash data blocks as it goes.
Data is copied in the blocks we defined above. There are 41 of them, numbered from 0 to 40, and they are copied in numerical order, starting with block #0. Each block's data is labelled with dashData0 through dashData40. Note that these labels point to the start of the data within each block, rather than the location of the block itself.
The first block starts at address &3000. The variable dashData is set to this address.
A new block starts every &80 bytes, i.e. &3000, &3080, &4000 and so on. The starting address for block I% is therefore dashData + &80 * I%.
As mentioned above, each block contains up to 79 (&4F) bytes of data. The data ends at offset &4F from the start of the block, and each block can contain any amount of data up to 79 bytes. So for block #0 at &3000, the last byte of data is at &304F. In this particular block there are 52 bytes of data, so the first byte of data is at address &3050 - 52 = &301C. This is therefore the address of the dashData0 label.
If you look at dashData0 in the source, you will see a SKIP 52 directive. This is because the dash data that lives at dashData0 is actually code that is assembled elsewhere, so it is moved into dashData0 after the code has been assembled, at the end of the BeebAsm source. We talk about this moving process below, but for now note that the 52 in the SKIP directive is the length of the data in that block.
The offset of the start of the data within each block is stored in the dashDataOffset table. This actually contains the offset of the last byte before the data starts, so the dashDataOffset entry for block #0 is &1B, as the data starts at &301C.
A typical block is therefore laid out like this, where I% is the block number:
|dashData + &80 * I%||Start of block I%|
|dashDataOffset,I%||Offset of the data within the block|
|dashData + &80 * I% + 80||End of block I%|
The dashDataAddress table contains two addresses, both for block #0. The first address points to block #0's location in main memory, and the second address points to block #0's location in screen memory. The table therefore contains:
- dashData, i.e. &3000, which is the address of block #0 in the main code
- &8000 - 80, i.e. &7FB0, which is the address that the start of block #0 maps to in screen memory
CopyDashData starts by copying block #0 from the first address to the second (assuming we are copying into screen memory - if not, we copy in the reverse direction). In this example, the entry in dashDataOffset+0 shows that block #0 contains 52 bytes of data, and the copy process only copies the data rather than the entire block, so this means that the data in block #0 gets copied from &301C-&304F into screen memory at &7FCC-&7FFF, and the data from block #1 will therefore end up in screen memory just before this, i.e. with its last byte at &7FCB.
CopyDashData repeats this process for all 41 blocks, and that's how the dash data jigsaw gets assembled before each driving session, and split apart after the session ends.
Blocks #41 and #42
For completeness, let's take a quick look at the image data in blocks #41 and #42, starting with block #42 at label dashData42. This is sliced up and spread throughout the game binary as part of the loader relocation, so by the time the MoveCode routine has finished running, the dashData42 block is in one piece and is already in the correct place in screen memory. Specifically, MoveCode moves the following parts of the dashboard42 image:
- &6C00-&6FFF stays where it is (the very top of the dashboard)
- &7000-&70DA comes from &1500-&15DA in the binary file (the top section of the steering wheel)
- &70DB-&7724 comes from &5300-&5949 in the binary file (the middle section of the dashboard)
The next part, dashData41, is at &594A-&598C in the binary file, and is swapped into screen memory at &7725-&7767 when the track data file is swapped into the main body of the game by the SwapCode routine.
Building a packed binary in BeebAsm
We've now covered the two jigsaw processes - the binary file jigsaw and the dashboard jigsaw - and it's clearly a pretty complex setup. Let's finish off by looking at how we can persuade BeebAsm to assemble this crazy mixed-up codebase and spit out a game binary that matches the released game.
Not surprisingly, there are quite a few stages we need to work through when creating the final Revs game binary in BeebAsm.
The first stage is to set up the memory map for when we are playing the game, and specifically for when we are driving around the track - in other words, the memory map after we call CopyDashData to copy the dash data into screen memory. This stage has two steps, both of which are pretty standard assembly tasks:
- Assemble all the game code at the addresses where that code runs.
- Load the dashboard image into screen memory with an INCBIN command.
At this point we have the memory map for when we are playing the game and driving around the track. However, the game starts in mode 7 rather than on the track, so the second stage is to pack the game into its mode 7 configuration. If the game were running, then this could be done by calling the CopyDashData routine with bit 7 of A set, but for the build process, we have to replicate this in BeebAsm, as follows:
- First, we split the dashboard image into 18 pieces and insert those pieces into the game code at 18 different addresses, in reverse order.
- Then we take the code that runs in screen memory, split it into 26 pieces, and insert those pieces into the game code at 26 different addresses, in reverse order.
This gives us the memory map as it is when the game first starts. i.e. just after the game binary has been unpacked.
The third and final stage is to take this memory map of the unpacked game, and package it up into a game binary that we can load from tape or disc. This unpacking is done by the Entry, MoveCode, SwapCode routines in the game, so our BeebAsm process therefore has to do the reverse of this, by packing the code into the game binary file. These are the steps we take:
- Take the completed game code, which is still in memory in the order in which it runs, and split it up and pack it back together in the order in which it appears in the game binary.
- In order to produce a game binary that exactly matches the original game file, we also need to zero one block of memory, and populate another with meaningless noise that is a consequence of the way the original game was built on a BBC Micro. We can omit this stage if we don't care about creating an exact duplicate of the released game binary, but we've come this far, so it's worth doing.
This three-stage process takes us from assembly code in BeebAsm to a final Revs binary file that matches the released game byte-for-byte. The first stage is standard assembly, but let's look at the second and third stages in more detail, starting with the reverse CopyDashData step.
Implementing CopyDashData in BeebAsm
To enable us to do this cleanly, we load the image binary outside of the code we've already assembled - I've chosen to load it at &9000, but any address outside of the assembled code would do.
Once loaded into memory, we simply copy the data into the relevant dashData blocks using BeebAsm's COPYBLOCK command, which has the following syntax:
COPYBLOCK start_addr, end_addr, destination_addr
Note that dashData25 contains both dashboard image (10 bytes at the start of the data) and assembled code (26 bytes at the end of the data), hence the +10 in the following.
ORG &9000 INCBIN "1-source-files/images/dashboard.bin" COPYBLOCK &9EF6, &9EF6+10, dashData25 COPYBLOCK &9ECD, &9EF6, dashData26 COPYBLOCK &9E99, &9ECD, dashData27 COPYBLOCK &9E61, &9E99, dashData28 COPYBLOCK &9E25, &9E61, dashData29 COPYBLOCK &9DE5, &9E25, dashData30 COPYBLOCK &9DA1, &9DE5, dashData31 COPYBLOCK &9D58, &9DA1, dashData32 COPYBLOCK &9D0B, &9D58, dashData33 COPYBLOCK &9CBE, &9D0B, dashData34 COPYBLOCK &9C72, &9CBE, dashData35 COPYBLOCK &9C38, &9C72, dashData36 COPYBLOCK &9C04, &9C38, dashData37 COPYBLOCK &9BD0, &9C04, dashData38 COPYBLOCK &9B9C, &9BD0, dashData39 COPYBLOCK &9B68, &9B9C, dashData40 COPYBLOCK &9B25, &9B68, dashData41 COPYBLOCK &9000, &9B25, dashData42
Next, we need to move the code that runs within screen memory. We first assemble the code where it runs, in screen memory from &7B00 to &7FFF, and then we use COPYBLOCK to split it up into 26 pieces, which fit into dashData0 through dashData25.
As noted above, dashData25 contains 10 bytes of dashboard image plus 26 bytes assembled code, hence the +10 in the following.
COPYBLOCK &7FCC, &8000, dashData0 COPYBLOCK &7F98, &7FCC, dashData1 COPYBLOCK &7F64, &7F98, dashData2 COPYBLOCK &7F2A, &7F64, dashData3 COPYBLOCK &7EDE, &7F2A, dashData4 COPYBLOCK &7E91, &7EDE, dashData5 COPYBLOCK &7E44, &7E91, dashData6 COPYBLOCK &7DFB, &7E44, dashData7 COPYBLOCK &7DB7, &7DFB, dashData8 COPYBLOCK &7D77, &7DB7, dashData9 COPYBLOCK &7D3B, &7D77, dashData10 COPYBLOCK &7D03, &7D3B, dashData11 COPYBLOCK &7CCF, &7D03, dashData12 COPYBLOCK &7CA6, &7CCF, dashData13 COPYBLOCK &7C82, &7CA6, dashData14 COPYBLOCK &7C5E, &7C82, dashData15 COPYBLOCK &7C3A, &7C5E, dashData16 COPYBLOCK &7C16, &7C3A, dashData17 COPYBLOCK &7BF2, &7C16, dashData18 COPYBLOCK &7BCE, &7BF2, dashData19 COPYBLOCK &7BAA, &7BCE, dashData20 COPYBLOCK &7B86, &7BAA, dashData21 COPYBLOCK &7B62, &7B86, dashData22 COPYBLOCK &7B3E, &7B62, dashData23 COPYBLOCK &7B1A, &7B3E, dashData24 COPYBLOCK &7AF6+10, &7B1A, dashData25+10
So BeebAsm has now split up the dashboard image and the code that runs in screen memory - i.e. all the dash data from block #0 to block #42 - and has copied each of the 43 parts into the correct place in memory. By this point, then, the memory map is at the correct state for when the game starts... so now we just need to persuade BeebAsm to split this up into the 13 parts that make up the binary file.
Implementing MoveCode and SwapCode in BeebAsm
We now move all the game code from where it runs (i.e. where it's been assembled by the above source code) to its position in the game binary file. The following commands therefore move blocks of code from their addresses when the game is running, to their addresses within the binary game file.
First, we have to replicate the block move in the Entry routine, which moves code from &1200-&12FF to &7900-&79FF and runs it there. We can this by assembling the block at &7900, but we need to assemble the code-moving part of the Entry routine at address &1200 (where it runs), and then move it to &7900 with the following:
COPYBLOCK &1200, &120E, &7900 CLEAR &1200, &120E
The CLEAR allows us to assemble the main game code at &1200 later on in the source file, without getting an overlap error.
Next, we implement the reverse of the SwapCode and MoveCode routines; they unpack the code from the game binary into memory, while the following does the opposite and packs the code from memory into the game binary. This is how it's done:
COPYBLOCK &5FD0, &6700, &64D0 \ Copy &5FD0-&66FF to &64D0-&6BFF COPYBLOCK &0D00, &16DC, &5A80 \ Copy &0D00-&16DB to &5A80-&645B COPYBLOCK &7000, &70DB, &1500 \ Copy &7000-&70DA to &1500-&15DA COPYBLOCK &70DB, &7725, &5300 \ Copy &70DB-&7724 to &5300-&5949 COPYBLOCK &0B00, &0D00, &1300 \ Copy &0B00-&0CFF to &1300-&14FF COPYBLOCK &7900, &7A00, &1200 \ Copy &7900-&79FF to &1200-&12FF CLEAR &645C, &64D0 \ Reset &645C-&64CF to zero
The second COPYBLOCK moves code out of &0D00-&16DB, and this vacated block then gets filled by further COPYBLOCK commands that copy code into &1200-&12FF, &1300-&14FF and &1500-&15DA. We are going to save the binary file from address &1200 onwards, as that's where the game binary loads, so we can ignore anything before &1200, but this still leaves a gap at &15DB-&16DB which has had code assembled into it, but that code has been moved elsewhere as part of the binary file packing process.
In the original game binary this block contains background noise from the original compilation process on the BBC Micro on which the game was assembled. This doesn't have any effect on the game, but if we want to assemble a file that matches the original game binary, we need to put this noise back. We can do that with a block of EQUB directives, as follows:
ORG &15DB CLEAR &15DB, &16DC EQUB &20, &00, &63, &60, &A6, &03, &10, &03, &20, &CB, &2A, &20 EQUB ...
We can now create the final game binary by saving the block of memory between &1200 and &6FFF... and we're done.