Amiga Bootblock demo source walkthrough
sizecoding amiga bootblock assemblerBulldogg bootblock intro
In my previous post, I showed how the demo looks, and many of the choices I made while building it, now I thought I’d share more of the specifics of my small bootblock.
The regular boot stuff
As mentioned, there is a certain amount of boilerplate one needs to run if you are intending to continue to boot normally from a custom bootblock. There are lots of cases where you would load more parts using trackdisk.device (conviniently left in register A1 during bootblock execution!) or with a totally custom 0xDFFxxx disk loader solution, possibly with speedups, but for all the cases you are not doing this you need to do the boring stuff. As shown from the hexdump in the previous post, you have to reserve space for three longwords, and then do the dos.library and expansion library dance:
dc.l "DOS"<<8
dc.l 0 ; checksum goes here later
dc.l 880
movem.l d0-d7/a0-a6,-(sp)
; Save all registers on the stack
; do the unpack and call it as a subroutine
; with BSR/JSR
movem.l (sp)+,d0-d7/a0-a6
; restore registers again
lea dos(pc),a1
jsr -96(a6) ; ExecBase in A6 already
tst.l d0
beq.b error
move.l d0,a0
move.l 22(a0),a0
moveq.l #1,d0
error:
subq.l #1,d0
rts
dos:
dc.b "dos.library",0
even
In this case, I opted for doing the old DOS\0 bootblocks which do not reference expansion.library, so save bytes. I guessed the old KS1.2-1.3 bootblocks would continue to work for many releases forward. Only small edit I did was the exit, where if you get a zero back from FindResident() you need to supply a non-zero return code, and vice versa. I just set it to 1 if it had a result, then subtract 1 from it at all times, so that it returns 0 in D0, and the pointer in A0. This removes one RTS, so there’s two bytes saved. Then you just need to have one version for each unpacker you are ever going to (try to) support.
Analyzing the sources for the intro
Build script
This script started super simple, then grew into something scary that definitely should have become a Makefile, but didn’t. In the end I had a set of tasks, starting with assembling the actual demo into an object file, and after that packing it with each kind of compressor (and for some other them, try many alternative settings and options and choosing the smallest output)
~/src/vasm/vasmm68k_motm68k -quiet -I ./include -Fbin -pic input.asm -o output
The -Fbin makes sure you only get the code output and no hunks or headers around it, the -pic is to force PC-relative, but this is also set in the source itself with the DevPac options " opt o+,p+". o+ means apply any obvious optimizations like turning a JSR to something within +/- 32k into a BSR and a BSR into BSD.s if the target is within +/- 127 bytes and so on, and p+ means force PC-relative code and fail to assemble if there ever is a fixed reference. This is the actual demo part we assemble here, and we will return to it later. Lets finish the build script description before ending with the actual demo.
Packers
I’ll show the Shrinkler version here, but the others are more or less the same. Compiling all the packers for your OS is left as an exercize for the reader, but most of them should compile fine on any unixy OS.
for i in 1 2 3 4 5 6 7 8 9; do
~/src/git/Shrinkler/build/native/Shrinkler -$i -d output tmp/output.shr.$i >/dev/null &
done
wait
cp tmp/$(ls -1S tmp/|tail -1) output.shr
What this does is to try all options from -1 to -9 backgrounded, write the outputs to tmp/, wait until all jobs have finished and then list those files in size-order with the smallest last, and copying that one out of the tmp/ dir.
Then you assemble the specific bootblock for this packer,
~/src/vasm/vasmm68k_motm68k -I ./include -Fbin -pic shr_bootblock1.asm -o bootblock1.shr
Bootblock Checksumming
Don’t remember from where I got the tool, but there are tons of published versions, this one is a bit annoying in that you give it an input file of 1024 bytes, but it makes the whole disk for you, though not as a “working formatted disk” as far as the rest goes. Still, it was easy enough to use dd to extract the bootblocks with the correct checksums out of it afterwards:
~/src/amigabootblockchecksum-and-adf/amiga bootblock1.shr > df0_shr
Use a decent ADF image as a template, this is a normal formatted floppy image with some short s:startup-sequence file in it and a few commands.
cp -f empty.adf empty_shr.adf
dd conv=notrunc if=df0_shr of=empty_shr.adf bs=1024 count=1
The conv=notrunc is so that dd does not truncate the empty floppy image when the input file (the bootblock) reaches end-of-file, and now we the build script has a full image with this bootblock added to it. I did some extra checks to make sure the size wasn’t above 1024, in which case it would just complain and skip the copying and dd parts. Since the ROM will not read past the first two sectors, if it doesn’t fit, its not going to work at all.
The actual demo part
Init
I will remove a lot of my comments from the actual source, and instead inline those thoughts here. We start off simple:
opt o+,p+
include hardware/custom.i
org $60000
start:
Again, opt o+,p+ for auto-optimizations and forcing pc-relative code, I include one of the AmigaOS include files to get nice labels on offsets for the 0xDFFxxx hardware registers we use. I also had to tell the assembler that we are locating the code at 0x60000 even though it’s all PC-relative. This was an odd sideeffect of me wanting to do a bit of maths on labels and while one might work around it, I knew this always unpacks at 60000 so I just told vasm the location so it would stop complaining.
Clear space after end of code
Then follows a long list of init routines for all the setup I needed to do, and first of all we clear a large amount of space behind the end of the code, and while I could calculate how many bytes this was exactly, and do either an unrolled loop for speed, or loop 25% of the bytes needed but write longwords on each loop, I lazied out and make it clear 64k bytewise instead:
lea bssbase(pc),a6
movea.l a6,a4
moveq #-1,d0
.bssclear:
clr.b (a4)+
dbf d0,.bssclear
The labels that start with a dot are local labels and belong to the preceeding non-local one (start: in this case). Most assemblers have some version of this. The moveq is a special instruction that replaces a “MOVE.L #$FFFFFFFF,D0”, which otherwise would have had to store the immediate value as all 32 bits, along with the MOVE instruction making this 6 bytes instead of the 2 that MOVEQ eats. So while it looks like this would do 2^32 rounds, the DBF (and all the other decrease-and-branch versions) only look at the counter as a 16bit entity, so while a more correct approach would have been to MOVE.W #(end-of-data - start-of-data) I make it run 65535 (or 65536? don’t care) times and just clear this many bytes to save space in the code.
One of the things I did to save space is to keep track of which registers are untouched during the run of the code, so the assignment of bssbase to A6 will remain for the whole run, and I will use it with offsets many times later on. This is the stuff that compilers are very good at, and that humans may or may not be better at when sources become very large.
Read in the bulldogg data
The bulldogg image is coded as three lists, one for which Y line the values belong to, and a start coordinate and the end coordinate for which to plot out dots. These are included at the end with the directive INCBIN which just reads it in during assembly. As I wrote, I tried to be a bit clever and abuse the fact that I could have a zero terminate the Y-coordinate list as the signal to end processing the image, and this meant that the Y array needed to be last.
lea files(pc),a1
lea 400(a1),a2
lea 400(a2),a0
lea bitmap_o(a6),a3
The code wanted the Y list in A0, the start in A1 and end in A2, and will plot in the bitmap pointed to by register A3, as a reference to A6 with the offset of the bitmap_o, relative to the end of the code which A6 already points to.
Plotting pixels
The data we read in points to where plots are to be placed, and plotting is always cumbersome on real hw for some reason. C64 is the same, though the layout of pixels in memory are different of course. My plot routine looks like this:
.plot:
move.l d3,d7
move.b #%10000000,d0
and.w #7,d3
asr.w #3,d7
lsr.b d3,d0
or.b d0,(a4,d7.w)
rts
We have the right line of the bitmap in A4, and the pixel number in D3 when calling this routine, so we save a copy of it in D7, then mask off the low 8 bits with the AND, shift the saved copy three steps to the right (divide by eight), shift the one-bit-set stored in D0 as many steps as D3 says, then OR this value into the address we get from combining D7 and A4, and return.
movem.l zero_o(a6),d0-d7
.loop:
move.b (a0)+,d0 ; dy to y
beq.s .done ; 0 = end of data
move.b (a1)+,d1 ; left += dl
move.b (a2)+,d2 ; right += dr
add.b d0,d4
add.b d1,d5
add.b d2,d6
; Calculate screen address: line = y * 40
move.l d4,d3 ; d3 = y
mulu #40,d3
lea (a3,d3.w),a4
; Set left bit
move.l d5,d3
bsr.s .plot
; Set right bit
move.l d6,d3
bsr.s .plot
bra.s .loop
This is the unpacker for the plots to paint, and since it uses more or less all registers, I clear all the data registers by having them filled from an array of zeroes with a single instruction. When I started coding, this was the zeroes that ended the Y list, but this moved about later. As for the code, it starts by reading the next Y value from A0, if zero skip out and we’re done. If not, read in next start and next stop, here described as left and right.
When this loop starts, D4,D5 and D6 start off as zero, so we add the values we read into them, since the numbers are delta encoded. Then I make the calculation for which memory address this Y line is at, and here I use the cycle-expensive MULU instruction that takes 40-60-80 cycles or something on the 68000, but is doesn’t really matter at this stage, size is far more important. Then we call the plot routine for the leftmost pixel, and while retaining the A4 value for the line, we call plot to paint the rightmost pixel.
Filling the blanks
We now have the outer edges of the bulldogg painted and need to fill in the areas in between. When the pixelplotter reached the end of the list, it jumps to .done.
.done:
move.w #7999,d7
moveq #0,d1
moveq #0,d4
eorl:
moveq #7,d3
move.b (a3,d1.w),d2
.next:
btst d3,d2
beq.s .notone
eor.w #-1,d4
bra.s .bitsloop
.notone:
tst.w d4
beq.s .bitsloop
bchg d3,(a3,d1.w)
.bitsloop:
subq.l #1,d3
bpl .next
.skip:
addq.l #1,d1
dbra d7,eorl
We need (possibly) to fill the whole bitmap, so 8000 loops it is. The astute m68k coder notices that I use A3, it was set in the previous routine and never changed, so a few bytes saved there for not having to reinitialize it here. Read in the data, use Bit-Test BTST on the byte to see if that bit is set, and if so start setting bits until a bit is read in again. I flip the flag (D4) by doing EOR #-1 on it to turn it from 0 to -1 and back again with the same instruction. I now see that a NOT instruction would have saved two bytes here.
I did try to use the blitter to make bitfills, it does have such a mode one can use, but I did not get it to work as I wanted, and I was not sure it would actually save bytes, so I skipped those tests and went for this cpu-based solution instead. When this is finished, we have a silhouette of a bulldogg in memory, ready to be shown.
Generating a copper list
lea cprfill_o(a6),a0
moveq #-2,d2
move.w #$2C07,d1
move.w #256-1,d4
Start by locating the place for the copper list in A0 relative to A6, setting the end-of-list 0xFFFFFFFE by using the sign-extending MOVEQ, set the start Y and X values to Y=2C and X=07. For some reason, copper lines start a bit early on the line before, so for background color changes one is supposed to wait 7 pixels in, and start doing color change there. Since this was later changed to the foreground color, and also had the sprite 0 X position added, it could have been waiting for the first and not the seventh pixel, but..
.loop:
move.w d1,(a0)+ ; WAIT IR1: VP:HP packed word
move.w d2,(a0)+ ; WAIT IR2: compare mask ($FFFE)
move.w #$0182,(a0)+ ; MOVE IR1: COLOR00 register address
clr.w (a0)+ ; MOVE IR2: color = 0
Write out D1 (2C07) to the copper list fill location, the compare mask FFFE, the 0x182 write location (ends up becoming 0xDFF182, the hw register location for the foreground color) and the value for the color, here 0x0 because of the CLR.W.
move.w #spr+sd_pos,(a0)+
move.l d4,d5
mulu #40503,d5
move.w d5,(a0)+ ; reuse loop counter as start value
add.w #$0100,d1 ; advance VP (high byte of WAIT word)
dbf d4,.loop
Add the copper command to change the sprite 0 X position, using the constants gotten from the include at the top of the file, which requires two values, and a third if it wasn’t the first sprite. Perhaps not a total win in the amount of characters one has to type, but there you are.
This is one of the places for a crappy RNG, take the loop counter and multiply it with 40503 and write out the lower 16 bits of the result as a start location for this lines X position of the star. Finally, bump the line counter with one, so 2C07 becomes 2D07 and so on, 256 times.
move.l #$FFDFFFFE,-352(a0)
move.l d2,(a0)+
For hw reasons, you are not allowed to wait for line 0, and of course this includes the second line 0 that happens on a PAL system when the Y line number wraps around after line 255. So the way to fix this in order to have an effect that uses line 0 is to wait for line FF, pixel DF and do the things there which you would have done on line 00 pixel 07 instead. A bit ugly, but I guess this is what people always have done. Guessing this offset is a bit boring and tedious. Lastly, write out an impossible value for the copper to wait for, which means it will not consume cycles until the vertical blank kicks it off again at the start of the next frame.
Creating gradient tables
The idea of spreading out 0x000, 0x111 -> 0xFFF grayscales with R,G,B values required me to have a short table,
dc.w $0001,$0100,$0010,$0101,$0011,$0110,$0111
for the values to add to the colors 0x000 to 0xEEE. It becomes a list of 8 * 14 values, and I will not go into each line of code, but just as a line drawing routine “knows” when it has to take a step and when not, you can paint 112 values into 128 by often, but not always, taking a step in the source list.
*
*
**
*
*
**
*
So one loop that generates the 112 values, then a second one much like Bresenhams line algorithm to “paint” these into an array of 128 entries to move from 0x000 to 0xFFF and then the same backwards from 0xFFF to 0x000 again, all in all 256 values which is very nice for wrapping around.
Some super small savings here by figuring out that D0 still is 0 from the end of my list generation through the whole of the copper list generation. For humans this is a very fragile method, which I noticed when moving data around. I also had a few subroutines in the two above-mentioned loops that I later inlined since there were only called once.
Sprite speed RNG
Apart from the initial X values of the sprite 0 position I set when building the copper list, they also need to move with different speeds in order to not look totally weird. So, yet another list creating routine, this one with a slightly better RNG.
.again:
move.b vhposr(a5),d0
beq.s .again
RNG_FILL3:
lea sprspeed(a6),a0
move.w #255,d3
.loop:
lsr.b #1,d0
bcc.s .skip
eori.b #$B8,d0
.skip:
moveq #3,d1
and.b d0,d1
addq #1,d1
move.w d1,(a0)+
dbra d3,.loop
First of all, many (poor) RNGs need a seed that is non-zero, so I just read out the X and Y position of the raster beam from VHPOSR. I know it might have low entropy, and obviously, if it actually manages to read 0, I will read it over and over like crazy until it isn’t zero, at which point it probably has a very predictable low value. We’re not building crypto here though. This is some version of XORSHIFT I googled up, mostly doing shift and EOR. The value gets ANDed with #3 which limits it to 0-3, then I add one, so final speeds are 1-4.
With this, generation of tables is mostly done, now to activate things, the base address for custom chip memory mapped register, 0xDFF000 has been loaded into A5 and we start talking to the hardware. When preparing the copper list, one thing stands out compared to other graphics systems, the amiga is autoincreasing the location of the bitplane(s) pointers while displaying them, which is ok line by line, especially since you can have it add modulo to each line if needed for interlace and other graphic tricks, but what is not obvious when you come from other platforms is that the memory location of your graphics is not reset again at vertical blanking, so if you don’t set it yourself (via cpu or preferably the copper), then the hardware will race through memory, showing a new 8kB section each frame.
This leads to why you almost always want to use the copper to handle graphics setup, so in order to have a stable image, we set the start of our bitmap into the bitplane1 pointer at 0xDFF0E0 every time the copper starts executing its list. Since the copper only does word writes, you need to write to 00e0 first, and 00e2 with the other half of the address. If you used more bitplanes then you would write to e4,e6 and so on.
This is the very end of the code section of my intro that actually holds any data, and from there on the rest is zeroed space:
BITMAP_ADDR equ bssbase+bitmap_o
copper:
dc.w $e2,(BITMAP_ADDR&$ffff)
dc.w $e0,((BITMAP_ADDR>>16)&$ffff)
and these two lines are why I had to set “ORG $60000” at the start of the source code, so this small piece of math would work out as a compile-time constant I could AND and shift to split into its two 16bit parts.
After these lines, I have reserved a lot of space for the parts of the copper bars that are generated, but since those values will be filled in by our loops, those are now part of the 64k I zero very early on. The end looks like this:
bssbase:
cprfill_o: rs.l 320*3
cprend_o: rs.l 1
zero_o: rs.l 8
oldint_o: rs.w 1
copskip: rs.w 1
copsine: rs.w 1
UPLIST: rs.w 106
OUTTAB: rs.w 256
sprspeed: rs.w 256
bitmap_o: rs.b 8000
BSSSIZE equ __RS
PRINTT "Size of RS.B at the end of asm"
PRINTV __RS
RS.x is Reserve Space, so it acts a bit like struct definitions, it just says how large each part is, in number of bytes, words or longs, but doesn’t actually fill it with values. This allows the code to calculate the offsets without any space actually being used. The copper starts with the E0 and E2 writes, then into this reserved space, and at cprend_o we wrote the endless wait value that acts as the end of the copper list.
The other values that control the display, like how many bitplanes to use, the modulo value, the X,Y,X1,Y1 size of the display are not changing so to fill out all those values, I (again) made a table and had it look a lot like the copper list, offset into 0xDFF000 and value to write. I do remember putting a lot of those into the copperlists when I did demos last time, and while it would save a few bytes to not have code to parse this list, it would also eat cycles on every frame resetting values that are not changing. So, a small list of settings to use, with few or no surprises, except DMACONW, which is the register used to set which DMA channels to enable or not.
Even though there is a DMACONR to read out the current value, in order to get exactly the channels you want, you need to write one value with all the channels to disable with bit 15 clear which will disable those specific channels, and then write once again with the selected bits and bit 15 set, to enable those. This is kind of straightforward I guess, except I had them in the correct order when I first traversed the list, then decided on flipping the order so I would not have to compare against the length of the list, but rather work from the end downwards until I passed zero. This meant I had to be careful to twist the order of the DMA-disable-and-enable writes, but the rest can go in any order.
dc.w diwstrt,$2C81
dc.w bplcon0,$1200
dc.w bplcon1,$0000
dc.w bplcon2,$0007
dc.w ddfstrt,$0038
dc.w ddfstop,$00D0
dc.w diwstop,$F4C1
dc.w fmode,$0000
dc.w bplcon3,$0c00
dc.w bplcon4,$0011
dc.w spr+sd_dataa,$01
dc.w spr+sd_dataB,$00
dc.w spr+sd_pos,$0078
dc.w spr+sd_ctl,$0000
dc.w dmacon,$8380
dc.w dmacon,$0020
dc.w intena,$7fff
Again the defines from the include file hardware/custom.i make the offsets a bit more readable. As I wrote in the previous post, this list ends up after the Y coordinates for the bulldogg, so moving DIWSTRT first here means there is a zero byte since DIWSTRT resolves to $008E, whereas BPLCON0 which I previously had first is $0100, which was what added one extra buggy line for me at one point.
The last entry is the one telling the system which IRQs to allow or not, and for this I just disable the lot. They are not needed for showing the intro nor reading the mouse button to continue. Still, the system is meant to recover so a bit of code to save old value before overwriting it is done. It also uses the same bit 15 for SET/CLR, so when writing it back, I have to OR.W #$8000.
Then we tell the system to start using our copper list by placing its address in COP1LOC and then writing to COPJMP1 in case it wasn’t enabled before to trigger a run. I don’t know all combinations of cold start from power-on to resets and crashes and what will and will not be enabled, so playing it a bit safe here.
From there on things starts to show and the only thing left to do is read the mouse button and if not pressed, apply each speed value to the respective X position of the sprite in the copper list to move the stars, move the greyscale colors one step upwards, then wait for a new frame to be drawn and test the mouse again, over and over.
Final words
Since my first post, some people have added ideas on how to pack the image better, even I found some small optimizations while looking at the code quoted here, so of course one can always improve on things, but I think of it as coding against a demo-compo deadline or something, you have to finish it at some point, and this was not meant to be a software release that comes in v1.102 later on. It was a fun exercise for me, and have given me lots of ideas on making amiga intros (not necessarily bootblock ones) since it was kind of fun to dive back into m68k asm coding again.
So, until the next intro, have a good day and I hope someone learned something or got inspired to code for the old systems. The last good piece of code has not yet been written, I’m sure of it.