Sunday, 9 September 2018

Freezing and the MEGA65 System Partition -- part 1

My current plan for the MEGA65 is that the core operating system will be focused around a hardware integrated freeze function, a bit like having a cartridge like the Action Replay built in.

Where we are going, you don't need one of these, as much as I love my Datel Action Replay (TM) when using a C64.


The idea here is that if you want to change disk images, or load a program, or switch to another program, these can all be logically handled from in the freezer.  Things like the current disk image are just flags in the Hypervisor's process descriptor for the currently running program.  Suspending and switching to other programs is of course the bread and butter of what a freeze cartridge does.

I started work on the freeze code a while back, and have just this week had a bit of time to come back to it, and got it to the point where freezing (mostly) works, although the un-freezer is yet to be written.

One of the challenges of the MEGA65 is that we have purposely made the core operating system only 16KB, partly to save hardware resources, and partly because the smaller something is, the less bugs it can hide.  Yet at the same time, we want to have a very functional and fun to use.

The way we are solving this dilemma of needing to be frugal versus making something that is a real joy to interact with, is that we are separating the Hypervisor functions from the freeze utility.  The Hypervisor will have functions to freeze and unfreeze, but will not have any user-interface built in.  Instead, when you hit the freeze/menu button, it will freeze the system to SD card, and then load the freeze menu program from SD card, in the form of a frozen system image, and give it special permission to make direct access to the SD card, so that it can look through the list of frozen programs, including pulling up the labels and thumbnail images that we can associate with them, and let the user choose one to load.  It will also let you change the floppy disk image attached (and switch to/from using the real floppy drive).


To make this work with the tiny hypervisor, we need to have an area of the SD card set aside for freeze images, and also for helper programs like the freeze menu.  We could have this in the FAT32 file system, but then we have to worry about whether files are contiguous, and update the FAT entries, all things that are rather a pain to do in a 16KB ROM.  Thus, instead, I have created the concept of a system partition for the MEGA65, that contains the freeze and service program slots, as well as a general configuration area, e.g., to remember whether you want 50Hz or 60Hz video on boot, and whether you want to enable 1351 emulation for Amiga mouses, and all the other sorts of things you want to remember between resets.

The system partition is created as a normal master boot record (MBR) partition, but with partition type 65 ($41).  Apart from this being the most obvious partition type number for us to want, it has the advantage that it isn't really used for anything on anything resembling a modern system.  Wikipedia says it was used for "Old Linux/Minix (disk shared with DR DOS 6.0) (corresponds with $81)", or "PPC PReP (Power PC Reference Platform) Boot".  Neither of these are likely to cause us any grief in typical usage.  Indeed, Linux, Windows and OSX all ignore partition type $41. This means we can have a MEGA65 system partition and a FAT32 partition for data storage, and if you take the microSD card out of the MEGA65 and put it into any modern computer, you will see the FAT32 partition, making it easy to transfer files, but won't be bothered by seeing the system partition.  If you want to modify the system partition, then this will be done using the existing MEGA65 system setup utility from the Hypervisor's Utility Menu, or by using the freeze menu.  So that's all fairly nicely sorted.

I hinted at the ability to have more than one service program installed in the system partition.  This is not accidental. The idea is that there can be many such programs installed on a MEGA65 system, and which can then be called from any program running on the MEGA65 in any mode.  This will happen by setting up a Service Program Call, which consists of setting up a little area of memory with the name of the service you want to call.  The Hypervisor then looks for a frozen service program with the same name, and loads it in, keeping only the first part of memory from the calling program as a transfer area. It can then do its job, say, downloading a file from a URL to SD card, and then updates the transfer area to say whether it succeeded or failed, and then calls a return-from-service-program Hypervisor trap that reverses the process.  This is a little, but only a little, like the Intent system on Android for inter-process communications.  Key differences include that the MEGA65 is never actually running multiple programs at the same time.

Writing the freeze routine was a bit interesting.  As mentioned, it has to be compact.  There is also the extra challenge that the MEGA65 has a lot more IO and little bits of state hanging around the place that need to be saved, including the SD card registers.  The usual path a freeze cartridge takes is to have some extra memory, so that you can stash a few things there while saving state. We have done the same, using the otherwise unused first 3KB of the 4KB BRAM we use for the SD card sector buffer.

To keep things compact, I made a list of memory regions and their sizes that have to be saved to the SD card, so that the table of memory regions to save/restore when freezing/unfreezing takes seven bytes each: four bytes for the
first address, followed by three bytes of length, allowing regions of up to 16MB to be represented.  I then added an eighth byte that is used as an entry in a look-up table that can contain special routines to prepare before saving the area.  This is used, for example, to save each of the four VIC-IV palette memories, only one of which can be mapped at a time.  The result is that the entire description of what to save, and how it should be done is a list of these regions.  Here is that list at the time of writing (we know there are a few things missing for now, and will add them in. Bonus points for working out what they are).

    ; SDcard sector buffer (direct access) and registers
    .dword $ffd6000
    .word $0290
    .byte 0
    .byte freeze_prep_stash_sd_buffer_and_regs

    ; SDcard sector buffer (F011)
    .dword $ffd6c00
    .word $0200
    .byte 0
    .byte freeze_prep_none

    ; Process scratch space
    .dword currenttask_block
    .word $0100
    .byte 0
    .byte freeze_prep_none
   
    ; $D640-$D67E hypervisor state registers
    .dword $ffd3640
    .word $003F
    .byte 0
    .byte freeze_prep_none

    ; VIC-IV, F011 $D000-$D0FF
    .dword $ffd3000
    .word $0100
    .byte 0
    .byte freeze_prep_none

    ; 128KB RAM + 128KB "ROM"
    .dword $0000000
    .word $0000     
    .byte 4          ; =4x64K blocks = 128K + 128K
    .byte freeze_prep_none   

    ; VIC-IV palette block 0
    .dword $ffd3100
    .word $0300
    .byte 0
    .byte freeze_prep_palette0

    ; VIC-IV palette block 1
    .dword $ffd3100
    .word $0300
    .byte 0
    .byte freeze_prep_palette1

    ; VIC-IV palette block 2
    .dword $ffd3100
    .word $0300
    .byte 0
    .byte freeze_prep_palette2

    ; VIC-IV palette block 3
    .dword $ffd3100
    .word $0300
    .byte 0
    .byte freeze_prep_palette3
   
    ; $D700-$D7FF CPU registers
    .dword $ffd3700
    .word $0100
    .byte 0
    .byte freeze_prep_none
   
    ; 32KB colour RAM
    .dword $ff80000
    .word $8000
    .byte $00
    .byte freeze_prep_none

    ; End of list
    .dword $FFFFFFFF
    .word $FFFF
    .byte $FF
    .byte $FF


The labels in bold are the index entries for the various setup routines. These are actually called by using a 65C02 jump table, using the following:

     jmp (freeze_prep_jump_table,X)

freeze_prep_jump_table:
    .alias freeze_prep_none 0
    .word do_freeze_prep_sdcard_regs_to_scratch
    .alias freeze_prep_palette0 2
    .alias freeze_prep_palette1 4
    .alias freeze_prep_palette2 6
    .alias freeze_prep_palette3 8
    .alias freeze_prep_stash_sd_buffer_and_regs 10
    .word do_freeze_prep_palette_select
    .word do_freeze_prep_palette_select
    .word do_freeze_prep_palette_select
    .word do_freeze_prep_palette_select
    .word do_freeze_prep_sdcard_regs_to_scratch


Where the X register has been set to the value of the eighth byte.  This is why the indexes are all even values, so that the jump table works without extra fiddling.   The .alias directives setup the value required to call the routine, which we do there in the jump table, so that it is easier to keep them in sync, if we modify things.

What is required in each setup routine differs somewhat. The palette ones are interesting, because all they need to do is to modify the VIC-IV memory map.  To save some space, the same routine is used for each, and uses the value of the X register that was used to do the jump into the jump-table:

do_freeze_prep_palette_select:
    ; X = 6, 8, 10 or 12
    ; Use this to pick which of the four palette banks
    ; is visible at $D100-$D3FF
    txa
    clc
    sbc #freeze_prep_palette0

    ; A now contains 0 if palette0, 2 if palette1 etc
    ; Shift it left six bits, so that that value 0-3 goes into
    ; the bits that control which palette bank is currently
    ; memory mapped
    asl
    asl
    asl
    asl
    asl

    ; Update VIC-IV memory map
    sta $d070
    rts

Thus we need only 13 bytes to implement four of these helper routines.  Some are more complex, in particular, the one that saves the state of the SD controller, since for that we have to save some registers and the SD card sector buffer itself into the 3KB RAM dedicated to the freezer I mentioned earlier.  That said, it isn't too complex, it is really just a case of copying the memory from the SD controller registers and sector buffers.

So that's the overall structure for how we are implementing freezing, and the result is reasonably compact and maintainable, which are our primary goals.  That isn't to say that we didn't have (and don't still have) bugs in the code.  To help track those down, and to make sure that programs are being frozen properly, we began writing a little Linux utility that reads an SD card and looks for a MEGA65 system partition and is able to find the freeze slots,  and save them out to files.  This helped us find some problems, such as if a single memory region was >64KB, the same 64KB was being saved repeatedly. That utility is still very much a work in progress, which we expect will get refined over coming weeks.


In terms of freezing speed, at the moment, it takes about one second to freeze using a class-10 microSD card.  This is mostly because our SD controller doesn't (yet) support sequential block writes.  This means the SD card writes an entire ~64KB flash block every time we write a 512 byte sector.  As a result, it spends perhaps 100x more time writing to flash than is required, and will wear the flash out that much faster too, in all likelihood. For both these reasons, we intend to implement sequential writes to the SD card.  Given our SD card interface is capable of about 3MB/sec, and freezing the entire state of the MEGA65, assuming all memory etc is being used, requires somewhere around 0.5MB, this should let us get the freeze time down to about 1/6 second, which seems like it should be pretty good.


Wednesday, 30 May 2018

Resurrecting the MEGA65 VNC server interface

Ages back we had a VNC server for the MEGA65, partly just for fun, and partly as a way to get nice digital screen shots of the MEGA65 for use here on the blog and elsewhere.  This all fell into disrepair after a while as we focussed on other things, including the change of video mode and the related activity around that, to make sure that the M65 platform is stable for some time to come.

Anyway, now I want to be able to make nice digital screen captures again, instead of taking photos all the time, so I have spent a couple of days getting it all working again, and trying to make it quite a bit better than it was before.

Back almost four years ago (gadzooks, we have been working on the MEGA65 for a long time now!) the VNC display was extremely lethargic, in part because at 1920x1200 we could transmit only 1 in every 13 raster lines over the 100mbit ethernet, as any more would have simply eaten all the bandwidth, because 1920x1200 = ~2Mpixels x 60 frames per second = 120MiB/sec = ~10x too much for 100Mbit ethernet.

But now that we are at 800x600, the bandwidth equation is quite a bit different.  800x600 = ~0.5Mpixels, and so a full 50 frames per second in PAL needs only 25MiB -- still too much, but tantalisingly close that I thought about how I could compress the data stream enough for it to work in the typical case.

100Mbit ethernet can realistically do about 10MiB of useful data per second, so we need to reduce the average data per pixel to < 10/25 bytes = ~3.2 bits per pixel.  At the same time, it would be nice to have at least 12-bit colour depth, so that the images look nicer than the old 3-3-2 8-bit colour cube I had to use with the old one.

I figured a nice bit-packed compressed format should do it: a 0 means repeat the same colour as last time, and 10 means use the most recently used colour.  With this, a 2-colour screen, like the C64 start-up screen should average (1+2)/2 = 1.5 bits per pixel -- easily within our envelope. But of course most of the time the border is a solid colour, as is much of the screen, so I also added a 16-bit sequence that indicates that upto 255 pixels of the same colour occur in a line.  Then I added some four-bit sequences so that we can cheaply switch among the five most recently used colours, as I figure this should probably be sufficient for most purposes.

Of course, this is all quite a bit more complex than the old one, and so most of the time has been spent debugging all the special cases of bit shuffling in the encoder, which is of course fully in hardware in VHDL, which makes it all a bit interesting.

After some effort, it mostly works. I can see the C64 and C65 start-up screens, and the C64 screen takes only about 0.5MiB per second to stream at 50Hz, so about 10KiB per frame, which is more than acceptable.

There are always lots of little fiddly bits with these sorts of things, as the state machine for the encoder (in VHDL) and the state machine for the decoder (in C) have to follow each other exactly.  I think it now matches nicely, but I am still seein the occasional glitched raster, which I think is when it switches from one packet to the next, or else it could be the little 32 bit buffer for the compresser overrunning, although since it happens even on the 2-colour C64 start-up screen, which definitely lacks the complexity to cause overruns, I suspect this is not the case. It could feasibly be lost packets as well, as the bit stuffing spans packets, without resetting.  Anyway, it results in only a few glitched lines per second, which while noticeable when it is running freely, are usually not there if you do a single-frame screen grab, which is the primary purpose of implementing it.

The bigger problem is that the pixel valid signal that tells the frame packer when to capture a pixel is not in sync with the output of the VIC-IV.  This is because the pixel valid signal does not pass through some of the compositing and filtering output stages, and thus arrives a few cycles early.  Because the output pixel clock is a non-integer fraction of the internal clock of the VIC-IV, there is jitter between the two, which means if the two aren't in sync you don't just get the wrong pixel in a consistent manner, but rather there is some variation as you scan across the line, that effectively distorts the display, like this:


The effect is particularly noticeable here with in 80 column mode, because each pixel corresponds exactly to one display pixel -- so we see some pixels doubled in width, while others disappear.  There are also some other little glitches here, like the display is not properly centred (which should be easy enough to fix), and the funny notching into the right border (which I am not sure if it is caused by the encoder or the decoder; I'll have to do some more testing to work out what the cause of that is.)

So, I added a single cycle delay to the pixel strobe signal, and now it is displaying much more nicely:

While it can't be seen here, the notching of the border is still happening, as is the occasional glitching line.  Nonetheless, it is now at the point of basic usability.

What is annoying, is that in the process a memory corruption bug has crept in. I am suspecting that this is due to lack of timing closure, but I can't be immediately sure.

The next step was to think about how I could setup an easy work flow for capturing high-quality video streams direct from the MEGA65 via this VNC feed.

A bit of digging around revealed that ffmpeg can capture directly from an X11desktop.  If I started the VNC server and viewer automatically, and worked out where the window was, I could indeed make it automatically record.  This is still a bit of a work in progress, but it already works (Linux only for now):

if [ "x$1" == "x" ]; then
  echo "usage: record-m65 <network interface>"
  echo ""
  echo "NOTE: You must first enable the ethernet video stream on the MEGA65"
  echo "      sffd36e1 29 from the serial monitor interface will do this."
  exit
fi
make bin/videoproxy bin/vncserver
pkill vncserver
sudo echo
sudo bin/videoproxy $1 &
sleep 1
bin/vncserver &
sleep 1
vncviewer localhost &
sleep 2
xwininfo  -name "VNC: MEGA65 Remote Display"
x1=`xwininfo  -name "VNC: MEGA65 Remote Display" \

    | grep "Absolute upper-left X:" | cut -f2 -d: | sed s'/ //g'`
y1=`xwininfo  -name "VNC: MEGA65 Remote Display" \

    | grep "Absolute upper-left Y:" | cut -f2 -d: | sed s'/ //g'`
wmctrl -a "VNC: MEGA65 Remote Display"
rm output.mp4

ffmpeg -video_size 800x600 -framerate 50 -f x11grab \
    -show_region 1 -i :0.0+${x1},${y1} output.mp4
pkill vncserver

Basically it makes sure you have told it which network interface to listen on, makes sure that the necessary tools from in the MEGA65 source tree have been built, and then runs the video proxy (this requires root, because at the moment it has to operate as a packet sniffer, because the video containing ethernet frames the MEGA65 produces are effectively raw frames), starts the VNC server to use that, and then uses xwininfo to figure out where the window is on the screen, uses wmctrl to bring that window to the foreground, and then runs ffmpeg to do the capture.

The main wrinkles in this at the moment are that the video stream does not contain any audio, so the recorded video is only video, without any sound, and that you hvae to manually stop the script to stop it recording, which means you typically end up with a second or two of terminal window output at the end.

Apart from that, however, it works very nicely, as the following video shows.  The capture is at a full 50Hz, and the quality is great.  Indeed, the quality is so great it records those glitches I mentioned earlier. Because it is a direct digital capture, the files are also quite small.  So in this case, where most of the screen is a single colour, 23 seconds of video requires only 330KiB, and I suspect a lot of that will actually be the second or so of Linux terminal window you see at the end.


You can also see the timing closure problem I mentioned, in the form of the corrupting of the last few bytes of the screen.  Finding and fixing the root cause of that is the priority for me now, followed by fixing the visual glitches.

So, fixing the timing closure problem turned out to be quite simple. So with that fixed, I could again run some software.  This time we have a 5.5MiB file for about 1.5 minutes, which is still very nice, around 60KiB per second, or a little over 1KiB per frame.   Of course if we add audio in, then this will go up a bit.  But for now, here is me trying to remember how to get the joystick working on PS/2 keyboard input and play a little Impossible Mission:


Some of the glitching in this video confirms that there is an encoder or decoder problem, as the border notching is in fact one too many pixels being decoded somewhere along the line.  It is possible that whatever that problem is, that it might in fact be the cause of the glitch lines, if it is actually the encoder and decoder failing to track state correctly. Hopefully those problems won't be too hard to track down.

[Edit: It looks like Blogger has munged the video from the nice crisp videos I uploaded.  However, overall the effect isn't all bad, as the videos look almost like a real CRT display.  So I'll just leave them as they are for now.  The full crispness can be seen in the screen grab above, in any case.]

Wednesday, 16 May 2018

Migrating from Xilinx ISE to Xilinx Vivado FPGA software

Until now, we have been using the old (and deprecated) Xilinx ISE software to compile the VHDL for the MEGA65 project. This is all a bit of an accident of history, because when the project first began in 2014, ISE was only just at end of life, and deficiencies in my VHDL programming style meant that I couldn't get it to work in Vivado.  Also, Vivado was less mature at the time.

Now, all that has changed: ISE is well and truly end of life, and approaching the zombie stage.  Vivado is now much more mature. But perhaps most importantly, Kenneth, one of our volunteers has put in a LOT of work silently in the background helping to move the project over to Vivado. 

The work involved, and the value to the project of doing this cannot be understated.

First, synthesis time under Vivado is fully 10x faster than under ISE.  This means we can do a synthesis run in ~10-15 minutes, instead of ~2-10 hours.  The benefit of this cannot be overstated.

Second, by fixing the semantics of memory access of the internal memories in the FPGA, a whole raft of instabilities have been fixed. These instabilities were causing differences in behaviour on different FPGA chips of the same model, and generally causing many lost hours due to chasing my tail on the symptoms of the problem, rather than the cause.

Third, Vivado achieves better timing closure.  This means that it is easier to get the design to run at the correct clock speed.  It also opens the door to increasing the clock speed in the future.

Finally, we are now somewhat future-proofed for ongoing development for the foreseeable future.

While most of the changes have been in the background, there are a few practical differences.  One of those is that various fixes along the way have improved our Bouldermark score somewhat to 38,980 (up from about 31,000).  This means we have a Bouldermark score 124x that of a stock C64. However, as previously explained, Bouldermark is a bit non-linear, in that the first few hundred points are quite a lot harder to get than the majority. This is consistent with the results of the Chameleon 64, which gets a Bouldermark score of 44.62x, but only 10.79x on Synthmark (our current Synthmark score is, for reference, 51x).



Kenneth gets an extra gold star for having found and fixed a problem with self-modifying code that was previously causing Bouldermark (and presumably other things) to not run stably.  This was all part of the same memory access semantics problems: In this case, it was possible for the CPU to begin fetching the next instruction before the RAM had time to update internally to present the updated value.  While this sounds absurd, when the clock cycles are only 20ns, propagation time inside components becomes a real consideration.

Wednesday, 11 April 2018

GOSUB variable in C64 BASIC

While writing a program with a student, we wanted to make an efficient dynamic jump table under C64 BASIC 2, where could have an array of line numbers that represent event handlers for particular events.  The idea is that we would then GOSUB JT%(EVENT) to dispatch to the appropriate handler, and be able to freely update the jump table as we go.

However, C64 BASIC doesn't allow this.  I am not really sure why they made the GOTO command (which is the heart of GOSUB also) unable to resolve variables. It would have meant that the ON ... GOTO command could have been left out, saving ROM space, for example.

Anyway, a bit of poking around found that other people have tried to do this, but none of their solutions really seemed particularly nice.

So, I thought, is it possible to do this just using BASIC 2, and without any assembly routines?

The answer is YES! Using the rather under utilised technique of self modifying BASIC programs.  Is this a horror beyond comprehension?  Probably.  But it also works a treat :)

So, here is how it works:

1. Have a GOSUB command somewhere in your basic program that you can reliably find in memory. I made it GOSUB,21456 in my program.  The number doesn't matter, provided it is 5 digits long, so that we have space to overwrite with any valid line number we might encounter.  The comma is there because GOSUB+COMMA = SYNTAX ERROR, and thus should not exist in any sensible program*. 

2000 REM THIS IS THE ROUTINE THAT WILL GET MODIFIED DURING EXECUTION
2010 GOSUB,21456
2020 RETURN

2. Find out where in memory that line lives, but searching for the GOSUB token (141) and the comma:

1000 FOR JA = 2048 TO 40959: IF PEEK(JA-1)<>141 OR PEEK(JA)<>44 THEN NEXT: RETURN


(By doing the comparison in the negative sense, the loop continues until it finds the location, and then aborts as soon as it finds it).

3. To GOSUB to any line, have a routine like this:

1100 REM GOSUB TO LINE IN VARIABLE LN
1110 LN$=STR(LN): REM GET STRING VERSION OF LINE NUMBER
1120 REM REPLACE ,21456 WITH CORRECT LINE NUMBER
1130 FORI=0TO5:POKEJA+I,32: REM FIRST RUB OUT WITH SPACES IN CASE LINE NUMBER IS SHORT
1140 FORI=0TOLEN(LN$)-1:POKEJA+I,ASC(RIGHT$(LEFT$(LN$,I),1)):NEXT
1150 GOSUB 2000
1160 POKEJA,44: REM PUT THE COMMA BACK READY FOR NEXT TIME
1170 RETURN

(The astute reader will realise that they can merge steps 1 and 3 to give something like:

1100 REM GOSUB TO LINE IN VARIABLE LN
1110 LN$=STR(LN): REM GET STRING VERSION OF LINE NUMBER
1120 REM REPLACE ,21456 WITH CORRECT LINE NUMBER
1130 FORI=0TO5:POKEJA+I,32: REM FIRST RUB OUT WITH SPACES IN CASE LINE NUMBER IS SHORT
1140 FORI=0TOLEN(LN$)-1:POKEJA+I,ASC(RIGHT$(LEFT$(LN$,I),1)):NEXT
1150 GOSUB,00000
1160 POKEJA,44: REM PUT THE COMMA BACK IN CASE WE WANT TO RUN AGAIN
1170 RETURN

Now you can GOSUB to any line you like with a simple program like:

10 GOSUB 1000: REM ONE-TIME ONLY LOOKUP PATCH ADDRESS
20 LN=12345: GOSUB 1100: REM GOSUB TO LINE 12345
999 END

Here is a complete program, and an example of it running. It is short enough to fit on a single screen, even with some comments!




* I don't claim that this program is sensible. It is also possible for it to show up in a string that has shift-M followed by a comma, but I can avoid that easily enough.

Sunday, 1 April 2018

Optimising infinite loops with VHDL

It's been a while since I have tried to improve the CPU performance of the MEGA65, so I thought I would take a look at an optimisation I hadn't tackled yet: Accelerating infinite loops.  This sort of loop isn't normally accelerated because they occur so rarely in programs on modern computers. 

However, on 8-bit systems it wasn't uncommon to have an infinite loop occupy the CPU, and the rest of the work occurring in interrupt routines.  This means that there is a surprising amount of CPU time wasted infinite loops that we can try to reduce.  We can get an idea of just how much potential benefit we can obtain by applying Amdahl's Law

Basically if 1/10th of the CPU time is spend on a particular task, this means that even if that task can be made to take no time at all, that we can only reduce the run time to 100% - 1/10, i.e., it will still take 90% of the original time.  This is where accelerating infinite loops has such great potential:  If the task would have taken infinite time, and we can reduce that to finite time, then we have gained a massive advantage.  Even better, if we can reduce the infinite time to practically zero, then we can gain an infinite speed up.  That is because, if we speed up the infinite part to take practically zero time, we have speed up the overwhelming majority of the task.  For any finite remaining run time, the ratio of speed up will be infinity / remaining run time, and since infinity divided by a finite quantity still equals infinity, the result is infinite speed up.

This all sounds great, but how can we go about achieving this in practice?  For a start, there are all sorts of infinite loops that we can have to contend with.  Again, a fortunate situation is that on 6502 systems the vast majority of infinite loops take the simple form of a JMP instruction that jumps to itself, such as:

2000 JMP $2000

The first trick is how to efficiently detect these operations. Fortunately this is simple: If the same JMP instruction is encountered two instructions in a row, it means that the code path has become invariant, and an infinite loop will eventuate. In fact, this method is robust enough that it can be used to detect a surprisingly wide range of infinite loops. The second trick is to know how to achieve the effect of the infinite loop. Fortunately JMP instructions don't perform any other computation, or cause unpredictable changes to the processor flags, so we can, in fact, simply ignore the instruction on the second iteration, falling through to the next instruction in memory -- thus effectively executing an infinite loop in a about 12 clock cycles, i.e., 12 x 20ns = 240ns.  That is, we can execute an infinite loop in about 1/4 of a single CPU cycle on the C64, or slightly faster than a single CPU cycle on a C65.  While there is scope to further improve on this, it will be good enough for now.

So, first step is to modify the CPU to detect back-to-back jumps to the same address, and to abort the jump when this occurs.  This turned out to be trivial. Here is the complete patch:

diff --git a/src/vhdl/gs4510.vhdl b/src/vhdl/gs4510.vhdl
index e2036f7..87b4852 100644
--- a/src/vhdl/gs4510.vhdl
+++ b/src/vhdl/gs4510.vhdl
@@ -1173,6 +1173,9 @@ architecture Behavioural of gs4510 is
   signal reg_math_cycle_counter_plus_one : unsigned(31 downto 0) := to_unsigned(0,32);
   -- # of math cycles to trigger end of job / math interrupt
   signal reg_math_cycle_compare : unsigned(31 downto 0) := to_unsigned(0,32);
+
+  signal reg_last_jump : unsigned(15 downto 0) := to_unsigned(0,16);
+  signal last_was_jump : std_logic := '0';
  
 begin

@@ -6136,8 +6139,16 @@ begin
               end if;
              
               if reg_microcode.mcJump='1' then
-                report "Setting PC: mcJump=1";
-                reg_pc <= reg_addr;
+                if reg_last_jump /= reg_addr or last_was_jump='0' then
+                  report "Setting PC: mcJump=1";
+                  reg_pc <= reg_addr;
+                else
+                  report "Accelerating infinite loop";
+                end if;

As can be seen, all we have had to do, was to add a couple of signals to remember if the last instruction was a jump, and where the jump went. We then only take the jump if the immediately preceeding instruction was not an identical jump.

A quick test using GHDL to verify that it works under simulation was the next item on the list.  For this, I added an infinite loop at the reset entry for the MEGA65 Kickstart. This would have normally caused the MEGA65 to take an infinite amount of time to proceed with the boot process.  But now, we see the following simulation output:

@5190ns:(report note): Setting PC to $80xx/8100 on hypervisor entry@5370ns:(report note): Setting PC: mcJump=1@5450ns:(report note): $8100 4C 88 A1  jmp  $A188
@5490ns:(report note): $A188 78        sei
@5610ns:(report note): Setting PC: mcJump=1@5690ns:(report note): $A189 4C 89 A1  jmp  $A189
@5810ns:(report note): Accelerating infinite loop@5890ns:(report note): $A189 4C 89 A1  jmp  $A189

@6010ns:(report note): Setting PC in JSR/BSR (fast dispatch)
@6050ns:(report note): $A18C 20 D8 A0  jsr  $0104
@6090ns:(report note): $0104 78        sei


I have highlighted the important lines above. As we can see, the infinite loop is entered at 5,610ns, and is detected at 5,810ns, and the following instruction is executed at 5,890ns.  Thus the total cost of the "infinite" loop was a mere 280ns -- a little slower than I predicted, but still quite acceptable.  It will certainly do for now, until I have time to track down where the other 40ns has gone.

Monday, 12 March 2018

Creating files on a FAT32 filesystem, and making it simpler to implement

The MEGA65 uses a VFAT32 file system as the native file system on the SD Card, both for convenience (pretty much any computer can read/write it), and also because it is a fairly simple on-disk format.  So far, however, the MEGA65 hypervisor DOS code can only read files, not create or write to them. This, naturally, is not a great situation, and thus this post describes progress towards being able to create files, and write to them.

One of the challenges is that the MEGA65 Hypervisor is only 16KB, including boot-up code, file system, freezing and task swapping, and a bunch of other miscellaneous other little things.  This means we have to be very careful about what we put in there.  Thus, for now at least, FAT file creation will also require the specification of the file length.  This means that we can have a call that allows writing data at any offset of a file, without having to worry about allocating space at that point in time.  It also means that we can fairly easily enforce that all files we create will have contiguous on-disk storage, which is important for D81 disk images, because the hardware support for disk images requires that they exist as a single contiguous slab on the disk, so that the hardware doesn't need to know anything about the FAT file system.

So, the first routine we need is one that can find a contiguous set of clusters that are free, and that are a candidate for hosting a file.  On the one hand, this is a very simple routine: All it has to do is to iterate through the cluster list, counting the number of consecutive empties, and resetting that count if it finds one already allocated, and continue until it reaches either the end of the partition, or finds the required number of consecutive unallocated clusters.  Of course, when you are manipulating 32-bit values on an 8-bit computer, everything ends up more complicated than you would like.  Here is my first go at implementing this (which I won't be able to easily test, until I have the rest of the routines in place):

dos_find_contiguous_free_space:
    ; Find a piece of free space in the specified file system
    ; (dos_default_disk), and return the first cluster number
    ; if it can be found.
    ;
    ; INPUT: dos_dirent_length = # of clusters required
    ; OUTPUT: dos_opendir_cluster = first cluster
    ; C flag set on success, clear on failure.
    ;
    ; FAT32 file systems have the expected first cluster free
    ; stored in the 2nd sector of the file system at offset
    ; $1EC (actually, this may point to the last allocated cluster).
    ; This field is only a suggestion however, to accelerate allocation,
    ; and thus should not be relied upon, but rather to allow quick
    ; skipping of the already allocated area of disk.
    ;
    ; The number of clusters we need to allocate is to be provided in
    ; dos_opendir_cluster as a 32-bit value, thus allowing for files
    ; upto 2^32 * cluster size bytes long to be created.
    ; (Note that in practice files are limited to 2GiB - 1 bytes for
    ; total compatibility, and 4GiB -1 for fairly decent compatibility,
    ; and 256GiB - 1 if we implement the FAT+ specification
    ; (http://www.fdos.org/kernel/fatplus.txt.1) in the appropriate places
    ; at some point, which we will likely do, as it is really very simple.
    ; Mostly we just have to use 40 bit offsets for file lengths.

    ; Let's start with a completely naive algorithm:
    ; 1. Begin at cluster 2. Reset # of contiguous clusters found to zero.
    ;    Remember this cluster as candidatee starting point.
    ; 2. If current cluster not free, advance to next cluster number, reset
    ;    contiguous free cluster count.  Remember the newly advanced cluster
    ;    number as candidate starting point.
    ; 3. If current cluster is free, increase contiguous free clusters found.
    ;    If equal to desired number, return candidate cluster number.
    ; 4. Repeat 2 and 3 above until end of file system is reached (and return
    ;    an error), or until step 3 has returned success.
    ;
    ; This algorithm can be made more efficient by using the last allocated
    ; cluster number field as an alternative starting point, and only if that
    ; fails, retrying beginning at cluster 2.

    ; So the state we need to keep track of is:
    ; dos_opendir_cluster = current cluster we are considering as candidate starting point.
    ; dos_file_loadaddress = current cluster being tested for vacancy.
    ; dos_dirent_length = number of clusters required, in total.
    ; dos_dirent_cluster = number of clusters required, from this point
    ; (i.e., the total minus the number we have already found contiguously free).
    ; current_disk[fs_fat32_cluster_count] = number of clusters in disk
    ; (and thus the end point of our search).
    ;
    ; Other than that, we just need to advance our way linearly through the FAT sectors.
    ; This is straight forward, as we can just read the first FAT sector, and then progress
    ; our way through, until we reach the end.

    ; First, make sure we can see the sector buffer at $DE00-$DFFF
   
JSR   sd_map_sectorbuffer

    ; 1. Start at cluster 2
   
LDA  #$02
    STA  dos_opendir_cluster+0
    STA  dos_file_loadaddress+0
    LDA  #$00
    STA  dos_opendir_cluster+1
    STA  dos_opendir_cluster+2
    STA  dos_opendir_cluster+3
    STA  dos_file_loadaddress+1
    STA  dos_file_loadaddress+2
    STA  dos_file_loadaddress+3

@tryNewStartingPointCandidate:

    ;    Reset # of clusters still required
   
LDX  #$03
@ll74:    LDA  dos_dirent_length, X
    STA  dos_dirent_cluster, X
    DEX 
    BPL  @ll74

@testIfClusterEmptyAfterReadingFATSector:
    ; Read the appropriate sector of the FAT
    ; To do this, we copy the target cluster number to
    ; dos_current_cluster, and call dos_cluster_to_fat_sector.
    ; This leaves the absolute sector number in
    ; dos_current_cluster.
   
LDX    #$03
@ll78:    LDA    dos_file_loadaddress, X
    STA    dos_current_cluster, X
    DEX
    BPL    @ll78
    JSR    dos_cluster_to_fat_sector
    ; Now we have the sector # in dos_current_cluster
    ; Copy to the SD card sector register, and do the read
   
LDX    #$03
@ll83:    LDA    dos_current_cluster, X
    STA    $D681, X
    DEX
    BPL    @ll83
    JSR    sd_fix_sectornumber
    ; Finally, do the read
   
LDA    #dos_errorcode_read_error
    STA    dos_error_code
    JSR    sd_readsector
    ; Fail on error
   
BCS       @ll93
    RTS
@ll93:

@testIfClusterEmpty:
    ; Here we have the sector read, and do the test on the contents of the cluster
    ; entry.

    ; But first, check that the cluster number is valid:
    ; 1. Get dos_disk_table_offset pointing correctly
   
LDX   dos_disk_current_disk
    JSR   dos_set_current_disk
    LDX   dos_disk_table_offset
    ; 2. Compare cluster count to current cluster
   
LDY   #$00
@ll128:    LDA   [dos_disk_table + fs_fat32_cluster_count + 0], X
    CMP   dos_file_loadaddress, Y
    BNE   @notLastClusterOfFileSystem
    INX
    INY
    CPY    #$04
    BNE    @ll128

    ; Return error due to lack of space
   
LDA    #dos_errorcode_no_space
    STA    dos_error_code
    CLC
    RTS

@notLastClusterOfFileSystem:

    ; The offset in the sector is computed from the bottom
    ; 7 bits of the cluster number * 4, to give an offset
    ; in the 512 byte sector. Once we have the offset, OR the
    ; four bytes of the cluster number together to test if = 0,
    ; and thus empty.
   
LDA      dos_file_loadaddress+0
    ASL
    ASL
    TAY
    LDA    dos_file_loadaddress+0
    AND    #$40
    BEQ    @lowMoby
    LDA    $DF00, Y
    ORA    $DF01, Y
    ORA    $DF02, Y
    ORA    $DF03, Y
    BRA    @ll120
@lowMoby:
    LDA    $DE00, Y
    ORA    $DE01, Y
    ORA    $DE02, Y
    ORA    $DE03, Y
@ll120:
    ; Remember result of free-test
   
TAX

    ; Increment next cluster number we will look at
   
LDA    dos_file_loadaddress+0
    CLC
    ADC    #$01
    STA    dos_file_loadaddress+0
    LDA    dos_file_loadaddress+1
    ADC    #$00
    LDA    dos_file_loadaddress+1
    LDA    dos_file_loadaddress+2
    ADC    #$00
    LDA    dos_file_loadaddress+2
    LDA    dos_file_loadaddress+3
    ADC    #$00
    LDA    dos_file_loadaddress+3

    ; If the cluster was not free, then reset search point
   
CPX       #$00
    BEQ     @thisClusterWasFree
    LDX     #$03   
@ll160:    LDA     dos_file_loadaddress, X
    STA     dos_opendir_cluster, X
    DEX
    BPL     @ll160   
    JMP     @tryNewStartingPointCandidate

@thisClusterWasFree:
    ; Decrement # of clusters still required
   
LDA    dos_dirent_cluster+0
    SEC
    SBC    #$01
    STA    dos_dirent_cluster+0
    LDA    dos_dirent_cluster+1
    SBC    #$00
    STA    dos_dirent_cluster+1
    LDA    dos_dirent_cluster+2
    SBC    #$00
    STA    dos_dirent_cluster+2
    LDA    dos_dirent_cluster+3
    SBC    #$00
    STA    dos_dirent_cluster+3
    ; Now see if zero
    ORA    dos_dirent_cluster+2
    ORA    dos_dirent_cluster+1
    ORA    dos_dirent_cluster+0
    BEQ    @foundFreeSpace
   
    ; Nope, we still need more, so continue the search

    ; Then check if this next cluster is unallocated?
    ; (If the cluster entry in the FAT will be in the same
    ;  sector as the last, then don't waste time recomputing
    ;  and reading the FAT sector number).
   
LDA    dos_file_loadaddress+0
    AND    #$7F
    BNE    @sameSector
    JMP     @testIfClusterEmptyAfterReadingFATSector
@sameSector:
    JMP    @testIfClusterEmpty

@foundFreeSpace:
    ; Found the requested space
   
SEC
    RTS


It isn't too hard to find the sections of code where there is considerable repetition due to the need to update 32-bit = 4 byte long values, using only 8-bit CPU operations.  This is both a nuisance from a code-size perspective (remember that the Hypervisor is only 16KB in total, and so every wasted byte is potentially important), and also a correctness/security perspective, because it is simply hard to make sure that such code is functionally correct.

If the criteria were only security and performance, we would of course just use a simple 32-bit processor, however, the whole point of the MEGA65 is that it is an 8-bit computer.  That said, the 6502 (and 4510) already both have a number of non-8-bit features: Most obviously is that the program counter is 16-bits.  But also the zero-page indirection instructions operate on 16-bit pointers, the stack can be 16-bit addressed on the 4510, the 4510 even has increment/decrement/shift word instructions, which really start to blur the lines on where the boundary between 8 and 16 bit processors lie.  I think my take on the situation is that while the registers are 8-bit, and the majority of operations are 8-bit, that there probably isn't a problem.

The question then really is whether it makes sense to add some simple 32-bit operations, for example increment and decrement, which are the most common kinds of operations, and which also are the most annoying to do now, because in reality you need to unroll them, or do very strange things with saving the processor flags between iterations of the loop, so that the carry flag is not corrupted.  These could be implemented using a prefix to the INW and DEW (increment and decrement word) instruction, similar to how the MEGA65's 32-bit zero-page indirect operations work.  So to increment a 32-bit value, we might end up using something like:

NOP
NOP
INW $address


That is, we can replace quite a lot of goo with just 5 bytes, if we allow INW and DEW to operate on 32-bits of data instead of 16. Unfortunately, INW and DEW (and the other 16-bit read-modify-write instructions) are all zero-page only, which is a bit limiting.  That they are zero-page only is a bit silly in retrospect, because these instructions already require 2 cycles for the instruction, 2 more to read the 16-bit value, and 2 more to write the result back = 6 cycles. For a seventh cycle, they would have been much more general purpose.  I could in theory make the instructions behave as absolute addressing, i.e., non-zero-page, when used with the double-NOP prefix, but they we might fall into the same trap as the 65816, where you have to know the CPU state, in order to be able to disassemble code.  This is not good on a variety of fronts.

To add to my frustration, the ROW and and ASW instructions, the ones that shift 16-bit values do use absolute addressing mode.  Even the PHW instructions take 16-bit addresses/values as operands, only not the INW/DEW instructions, that are the most useful in this situation.

So looking at our available resources, we have PHW #$nnnn, PHW $nnnn, ASW $nnnn and ROW $nnnn as the four instructions that we could potentially overload with a prefix to do something more useful, and INW/DEW that can also be logically extended, but with the annoying limitation on their source of operand.  There is some intrinsic value to having 32-bit ROW and ASW operations, to allow bit-shifting of wider values.  The stack operations are more vexed, because there are no matching 16-bit stack pop operations (assuming that we don't count RTS, although it technically does pop 16 bits from the stack, it is just that any instruction that puts its result into the program counter is perhaps not ideally suited to being bent towards arithmetic operations.


For the existing 32-bit flat addressing extensions, i.e., where the data remains 8-bits wide, but the address is 32-bits long, we use NOP + NOP as the prefix. Note that this is different to what we are talking about here, where the data is 32-bit, but the adress remains 16-bit.  We really don't want to confuse those two, and there are situations where it probably makes sense to use them both at the same time*, so we will need a different prefix. 

[ * The main problem with using them at the same time, is that the 32-bit addressing extensions use the indirect zero-page instructions like LDA ($12), Z -- which uses one of the registers in the address computation. This is fine for LDA, but not for ADC and friends, and definitely not for STA, where the value you are storing, including all the 8-bit register values as it does, would thus affect the address you are writing to.  To solve this for now, if you use STA in this mode, the value of the Z register is NOT added to the 32-bit address.  This whole scheme will need some further fine-tuning, but at least for now is minimally useful: It is possible in theory to load a 32-bit value from, or write one to, a 32-bit flat address. ]


After having stared at the instruction set many times before, and not really seen many good candidates for prefix instructions, since, after all, the whole point of instructions that are not called NOP is to do something, it occurred to me that the NEG instruction would in fact be a pretty good one.  Yes, it will mess up the Z and N flags -- but that's all it will do if you call it twice, since it negates, i.e., inverts the sign of the value of the accumulator.  Thus, calling it twice puts the value of the accumulator back to what it was to begin with, and potentially even restores the values of the Z and N flags.  Anyway, for lack of a better alternative, this is what we will use.

Anyway, by using the NEG + NEG prefix on any LDA, STA, ADC, SBC, EOR, AND, ORA, INC, DEC, ASL, ROL, ROR or LSR instruction, we can make it instead do a 32-bit read (and or write).  The simplest way to implement this, and which is what I have done, is to duplicate the operand read and/or write after the address has been computed.  In this way, you can still use all the usual addressing modes (although the indexed ones will require some care, because many of these 32-bit operations will of course modify the contents of the index registers). This leaves the question of where do we find a 32-bit register in the 6502 architecture that we can use.  That, and related questions we will soon return to. But first, lets get back to the routine for finding where to put a newly created file, there are the following 32-bit operations:

2x write 32-bit value to memory
4x copy 32-bit value to another location
1x compare two 32-bit values for equality
3x test 32-bit value if equal to zero
1x increment 32-bit value
1x decrement 32-bit value

Of these, the increments, decrements and zero-tests could be directly accelerated, accounting for 5 of the 13 operations.  Helpful, but not ideal. 

What simple changes can we make that would give a much better result? One of the requirements for "simple" in my mind, is that they should not result in the creation of more registers or other CPU state that needs to be saved/restored during a context switch or Hypervisor trap.  We do have four general purpose(ish) registers in the 4510, however: A, X, Y and Z  - just enough for holding 32-bits.  Thus we could have a prefix on LDA / STA / ADC / SBC / CMP that cause A, X, Y and Z to all be used together, so for example:

NEG
NEG
LDA $1234
CLC
NEG
NEG
ADC $1238
NEG
NEG
STA $123C

could be used to read the 32-bit value from $1234-$1237 into A, X, Y and Z, and then add to this the 32-bits of data stored at $1238-$123B, and finally write the results to the four bytes at $123C-$123F.  The same routine in normal 6502 would be:

LDA $1234
CLC
ADC $1238
STA $123C
LDA $1235
ADC $1239
STA $123D
LDA $1236
ADC $123A
STA $123E
LDA $1237
ADC $123B
STA $123F

Not only does this require 37 bytes and 50 cycles on a regular 6502, it is also prone to copy-paste errors (hopefully I didn't make any here).  It is possible to slightly optimise this, e.g.:

LDX #$00
CLC
PHP
loop:
PLP
LDA $1234,X
ADC $1238,X
STA $123C,X
PHP
INX
CPX #$04
BNE loop
PLP

This is shorter than the unrolled version, requiring only 21 bytes, however the program flow is rather more complicated to understand (and requires a byte off stack space).  Also, it requires 99 cycles, making it twice as slow as the unrolled version.

If we implement our CPU extension to allow our nice clear 15 byte version, it should take only 30 cycles* on the MEGA65, making it superior on all three fronts: size, speed and simplicity. 

* NOP and CLC take 1 cycle on the MEGA65, and load instructions have a one cycle increased cost, compared to the 6502.  We also have to allow four cycles for reading/writing the 32-bit values in this mode, so my current estimation of the cycle timing would be:

= 1 (NEG)
+ 1 (NEG)
+ 8 (LDA, $34, $12, wait state, read $1234, read $1235, read $1236, read $1237)
+ 1 (CLC)
= 1 (NEG)
+ 1 (NeG)
+ 8 (ADC, $38, $12, wait state, read $1238, read $1239, read $123A, read $123B)
+ 1 (NEG)
+ 1 (NEG)
+ 7 (STA, $3C, $12, write $123C, write $123D, write $123E, write $123F)

= 30 cycles.

Also, it stands to reason that there are lots of other situations where being able to read multiple bytes into the registers, or write all the registers out at the same time will probably be useful. Anyway, back to our routine, let's look at how it would look if we had these extensions:

    ; First, make sure we can see the sector buffer at $DE00-$DFFF
   
JSR   sd_map_sectorbuffer

    ; 1. Start at cluster = $00000002

    ; (Use new extensions to store AXYZ with the values)
   
LDA  #$00

    TAX
    TAY
    TAZ
    LDA  #$02
    NEG
    NEG
    STA  dos_opendir_cluster

    NEG
    NEG
    STA  dos_file_loadaddress
 
@tryNewStartingPointCandidate:

    ;    Reset # of clusters still required
   
NEG

    NEG
    LDA dos_dirent_length
    NEG
    NEG
    STA dos_dirent_cluster

@testIfClusterEmptyAfterReadingFATSector:
    ; Read the appropriate sector of the FAT
    ; To do this, we copy the target cluster number to
    ; dos_current_cluster, and call dos_cluster_to_fat_sector.
    ; This leaves the absolute sector number in
    ; dos_current_cluster.
   
NEG

    NEG
    LDA dos_file_loadaddress
    NEG
    NEG
    STA dos_current_cluster 
    JSR    dos_cluster_to_fat_sector
    ; Now we have the sector # in dos_current_cluster
    ; Copy to the SD card sector register, and do the read
   
NEG

    NEG
    LDA dos_current_cluster
    NEG
    NEG
    STA $D681
    JSR    sd_fix_sectornumber
    ; Finally, do the read
   
LDA    #dos_errorcode_read_error
    STA    dos_error_code
    JSR    sd_readsector
    ; Fail on error
   
BCS       @ll93
    RTS
@ll93:

@testIfClusterEmpty:
    ; Here we have the sector read, and do the test on the contents of the cluster
    ; entry.

    ; But first, check that the cluster number is valid:
    ; 1. Get dos_disk_table_offset pointing correctly
   
LDX    dos_disk_current_disk
    JSR    dos_set_current_disk
    LDX    dos_disk_table_offset
    ; 2. Compare cluster count to current cluster
    ; (Note that here we can use the previous value of X still as an index,
    ;  because it doesn't get overwritten by the 32-bit load until after the 
    ;  address has been computed. Of course, this doesn't work for a store
    ;  operation, because in that case the index registers are forming part of
    ;  the 32-bit value to be written).
    NEG
    NEG
    LDA    [dos_disk_table + fs_fat32_cluster_count + 0], X

    NEG
    NEG
    CMP    dos_file_loadaddress

    BNE    @notLastClusterOfFileSystem

    ; Return error due to lack of space
   
LDA    #dos_errorcode_no_space
    STA    dos_error_code
    CLC
    RTS

@notLastClusterOfFileSystem:

    ; The offset in the sector is computed from the bottom
    ; 7 bits of the cluster number * 4, to give an offset
    ; in the 512 byte sector. Once we have the offset, OR the
    ; four bytes of the cluster number together to test if = 0,
    ; and thus empty.
   
LDA    dos_file_loadaddress+0
    ASL
    ASL
    TAY
    LDA    dos_file_loadaddress+0
    AND    #$40
    BEQ    @lowMoby

    NEG
    NEG
    BIT    $DF00, Y
    BRA @ll120
@lowMoby:
    NEG
    NEG
    BIT    $DE00, Y
@ll120:
    ; Remember result of free-test
   
PHP

    ; Increment next cluster number we will look at
   
NEG

    NEG
    INC    dos_file_loadaddress

    ; If the cluster was not free, then reset search point
   
PLP
    BEQ    @thisClusterWasFree



    NEG
    NEG
    LDA    dos_file_loadaddress
    NEG
    NEG
    STA    dos_opendir_cluster
 

    JMP    @tryNewStartingPointCandidate

@thisClusterWasFree:
    ; Decrement # of clusters still required
   
NEG

    NEG
    DEC    dos_dirent_cluster
    ; Now see if zero
    BEQ    @foundFreeSpace
   
    ; Nope, we still need more, so continue the search

    ; Then check if this next cluster is unallocated?
    ; (If the cluster entry in the FAT will be in the same
    ;  sector as the last, then don't waste time recomputing
    ;  and reading the FAT sector number).
   
LDA    dos_file_loadaddress+0
    AND    #$7F
    BNE    @sameSector
    JMP     @testIfClusterEmptyAfterReadingFATSector
@sameSector:
    JMP    @testIfClusterEmpty

@foundFreeSpace:
    ; Found the requested space
   
SEC
    RTS


That's quite a bit shorter and simpler, and would be even simpler again if we were to teach the assembler about the new extensions, and have LDA32, STA32, ADC32, INC32 etc generate the NEG, NEG prefix:

    ; First, make sure we can see the sector buffer at $DE00-$DFFF
   
JSR   sd_map_sectorbuffer

    ; 1. Start at cluster = $00000002

    ; (Use new extensions to store AXYZ with the values)
   
LDA  #$00

    TAX
    TAY
    TAZ
    LDA  #$02
    STA32  dos_opendir_cluster
    STA32  dos_file_loadaddress
 
@tryNewStartingPointCandidate:

    ;    Reset # of clusters still required
   
LDA32  dos_dirent_length

    STA32  dos_dirent_cluster

@testIfClusterEmptyAfterReadingFATSector:
    ; Read the appropriate sector of the FAT
    ; To do this, we copy the target cluster number to
    ; dos_current_cluster, and call dos_cluster_to_fat_sector.
    ; This leaves the absolute sector number in
    ; dos_current_cluster.
   
LDA32 dos_file_loadaddress

    STA32 dos_current_cluster 
    JSR    dos_cluster_to_fat_sector
    ; Now we have the sector # in dos_current_cluster
    ; Copy to the SD card sector register, and do the read
   
LDA32  dos_current_cluster

    STA32  $D681
    JSR    sd_fix_sectornumber
    ; Finally, do the read
   
LDA    #dos_errorcode_read_error
    STA    dos_error_code
    JSR    sd_readsector
    ; Fail on error
   
BCS       @ll93
    RTS
@ll93:

@testIfClusterEmpty:
    ; Here we have the sector read, and do the test on the contents of the cluster
    ; entry.

    ; But first, check that the cluster number is valid:
    ; 1. Get dos_disk_table_offset pointing correctly
   
LDX    dos_disk_current_disk
    JSR    dos_set_current_disk
    LDX    dos_disk_table_offset
    ; 2. Compare cluster count to current cluster

    ; (Note that here we can use the previous value of X still as an index,
    ;  because it doesn't get overwritten by the 32-bit load until after the 
    ;  address has been computed. Of course, this doesn't work for a store
    ;  operation, because in that case the index registers are forming part of
    ;  the 32-bit value to be written).
   
LDA32  [dos_disk_table + fs_fat32_cluster_count + 0], X

    CMP32  dos_file_loadaddress
    BNE    @notLastClusterOfFileSystem

    ; Return error due to lack of space
   
LDA    #dos_errorcode_no_space
    STA    dos_error_code
    CLC
    RTS

@notLastClusterOfFileSystem:

    ; The offset in the sector is computed from the bottom
    ; 7 bits of the cluster number * 4, to give an offset
    ; in the 512 byte sector. Once we have the offset, OR the
    ; four bytes of the cluster number together to test if = 0,
    ; and thus empty.
   
LDA    dos_file_loadaddress+0
    ASL
    ASL
    TAY
    LDA    dos_file_loadaddress+0
    AND    #$40
    BEQ    @lowMoby

    BIT32    $DF00, Y
    BRA @ll120
@lowMoby:
    BIT32    $DE00, Y
@ll120:
    ; Remember result of free-test
   
PHP

    ; Increment next cluster number we will look at
 
  INC32    dos_file_loadaddress


    ; If the cluster was not free, then reset search point
   
PLP
    BEQ    @thisClusterWasFree



    LDA32    dos_file_loadaddress
    STA32    dos_opendir_cluster

    JMP    @tryNewStartingPointCandidate

@thisClusterWasFree:
    ; Decrement # of clusters still required    DEC32    dos_dirent_cluster
    ; Now see if zero
    BEQ    @foundFreeSpace
   
    ; Nope, we still need more, so continue the search

    ; Then check if this next cluster is unallocated?
    ; (If the cluster entry in the FAT will be in the same
    ;  sector as the last, then don't waste time recomputing
    ;  and reading the FAT sector number).
   
LDA    dos_file_loadaddress+0
    AND    #$7F
    BNE    @sameSector
    JMP    @testIfClusterEmptyAfterReadingFATSector
@sameSector:
    JMP    @testIfClusterEmpty

@foundFreeSpace:
    ; Found the requested space
   
SEC
    RTS


The end result is that the routine can be reduced to 162 bytes instead of 215 (assuming I have counted correctly).  This is really quite a nice improvement -- especially since as we have mentioned, the reduction in size comes with a reduction in execution time and complexity. In short, it will be smaller, faster, simpler -- exactly what we want.

The next step is to test all this, and make sure that it actually works -- both the routine, and the CPU extensions.