+Andrew Davie Posted June 11, 2020 Share Posted June 11, 2020 (edited) A guide to using 3E+ bankswitching 3E+ is an Atari 2600 bank-switching format designed by Thomas Jentzsch, derived from the DASH bank-switching scheme designed by Andrew Davie, which in turn was derived from the 3E bank-switching scheme designed by Armin Vogl which was in turn derived from the method used in Tigervision carts. The scheme divides the '2600 address space into 4 "slots". Each slot (numbered 0 to 3) lives in its own address space in the '2600 memory. We shall use the address range $F000 - $FFFF to refer to the 4K area the '2600 accesses. Each slot is 1K in "size" SLOT# Address range 0 F000 - F3FF 1 F400 - F7FF 2 F800 - FBFF 3 FC00 - FFFF A slot can reference either a RAM "bank" or a ROM "bank". A "bank" refers to a unique block of RAM or of ROM in the game's binary. ROM banks are numbered from 0 to 63 (maximum) RAM banks are numbered form 0 to 63 (maximum) To "switch in" a ROM bank to a SLOT, write the encoded SLOT/BANK number to $3F The encoding of SLOT/BANK numbers is as follows... D7-D6 the slot number (0 - 3) D5-D0 the bank number (0 - 63) Thus, if you write $C7 to $3F, then C7 in binary is %1100111 D7-D6 = %11 = 3 = SLOT 3 D5-D0 = %00111 = 7 = BANK 7 So, this would switch the 8th 1K bank (counting from 0) from the game binary into SLOT 3 (which is address range $FC00 - $FFFF). RAM banks are similar to ROM banks, except they are only 512 bytes long, and to switch you write to $3E instead of $3F. So, $3E is the bank-switching address for RAM, and $3F is for ROM. Note that you can only have either ROM OR RAM (not both) switched in to any single SLOT. But you can have combinations of ROM and RAM switched into the 4 slots, total. SLOT 0 could be RAM and SLOTS 1, 2, 3 could be ROM... or any other combination you care to name. One type per SLOT. To read from a RAM bank address, use the address as with any other read instruction. TO write to a RAM bank address, you must add 512 to the address. All of this may seem a bit confusing, but fortunately there are a whole bunch of very simple macros to get it all happening fairly automatically for you. First, SLOT n This will let the assembler know that the following code is assembled for slot number "n" (where "n" is from 0 to 3). The RORG and other internal dasm stuff is handled automatically. All you need to do if you change your mind which SLOT code should live in, is change the "n" to the new slot. Now, if you have code that is running in SLOT 0 (for example), then it is somewhere in the address range F000 - F3FF. If you wanted to bank-switch some code or data you wanted to access, then you would first "switch in" the desired bank and then do the access. So, how do you "switch in" a desired bank into a desired slot? Well we covered this a bit earlier... lda #(SLOT*64) + BANK sta $3E ; switch in RAM But if we continued down this path, we'd have a LOT of numbers to remember, and to change if we moved things from different banks to other banks and used different slots for something. We'd have to go through all our code and fixup stuff. A nightmare. So, instead of that, we're going to use more macros to do all the work for us. The first macro to facilitate this is the "SLOT" macro mentioned above. Let's introduce a new way of declaring functions. Normally in dasm, you'd just type the name of the function in the first column (so, it's a "label") and dasm will assign it an address when it assembles. That's fine, but we need more than an address - we need a bank # as well. So, the macro "DEF" (stands for define) does this for us. Instead of typing the function name in the first column, we just tab out and type DEF myFunction (where "myFunction" is the name of your function/subroutine/label). Now the first thing this macro does is simply put the "myFunction" as a label as if you typed it in the first column yourself. Totally identical, except now you have to type more... "DEF". But there is great value in doing this... Firstly, DEF now also defines a new label you can use called "BANK_myFunction" So if we want to switch in a bank containing the function, and then call the function, we can do this... lda #BANK_myFunction sta $3F jsr myFunction Well that's a bit nicer. We now don't have to worry where the function is. But hang on, what about which SLOT we have just switched in? Well, that was set by an earlier use of the SLOT macro. So, here's the basic setup so far... SLOT 2 ; all following code lives in slot #2 (from F800-FBFF) DEF myFunction ; code rts SLOT 0 ; all following code lives in slot 0 (from F000-F3FF) lda #BANK_myFunction sta $3F ; switch ROM (and SLOT 2) jsr myFunction OK, hopefully that makes sense. We define SLOT to indicate where following code actually lives in the '2600 memory footprint, and we use DEF to automatically generate the BANK value we use for switching banks. Good so far.... While we're at it, let's stop using $3E and #3F -- too easy to confuse. There are two equates for these SET_BANK = $3F SET_BANK_RAM = $3E So, we'll use those from now on... lda #BANK_myFunction sta SET_BANK ; switch ROM/SLOT jsr myFunction Now, that's the sort of construct we'll probably see a lot. Switch in a bank, and then call a function in that bank. But, consider the following... SLOT 0 DEF myFunction rts ... and in some other bank.... SLOT 0 lda #BANK_myFunction sta SET_BANK jsr myFunction This will fail. Horribly. It will fail because we're running code that lives in SLOT 0, and we have just tried to switch in the bank cotaining myFunction into that slot. The slot we're currently running code from. This is something we definitely want to avoid, and fortunately this can be automated. Two macros allow this. CALL function This macro will insert exactly the above bit of code (the bankswitch/jsr) but first it checks the bank of the current code and the code being called to make sure they do not live in the same SLOT. That is, make sure that we're not effectively "pulling the rug from under our own feet" by switching out the bank we're running in. So, the above code, rewritten... SLOT 0 DEF myFunction rts ... and in some other bank.... SLOT 0 CALL myFunction This will now generate an assemble-time error - "attempt to call function in incompatible bank" In other words, you're saved from a difficult-to-debug runtime error by an assemble-time check. Instead of CALL, there is another similar macro to use for jmp -- JUMP -- which performs the same check on the current and destination banks. OK, that's pretty cool. How do we define which BANK some code lives in. Well the short answer is, we don't. The BANK something is in really depends on where it is in the binary file itself. At least the ROM bank behaves that way. There's a macro to start a new ROM bank, and that uses SLOT to also indicate which memory area the code in the bank should be assembled for. That is, if we have code that we switch into SLOT 1, then that code should be assembled to live in that slot's address range (that is, $F400-$F7FF). Rather than having to do that ourselves, it's all automatically done for us with the ROMBANK macro. General usage is thus... SLOT 3 ROMBANK DEMO DEF fn rts SLOT 2 ROMBANK B2 DEF main CALL fn In the above example, we have defined two BANKS. The first has a function named "fn", which is assembled for use in SLOT 3 (ie. FC00-FFFF range). The second has a function named "main" which is assembled for use in SLOT 2 (ie. F800-FBFF range). The "main" function CALLs "fn" by switching the BANK that "fn" lives in (which in the above example would be 0) and then doing a jsr to the function. dasm will be happy because the CALL sees that "fn" and "main" are in different SLOTs. OK, so for RAM banks, we use RAMBANK instead of ROMBANK. Thus, SLOT 0 RAMBANK BUFFER DEF Buffer ds 32 SLOT 1 ROMBANK DEMO DEF main lda #BANK_Buffer sta SET_BANK_RAM lda Buffer+16 ; load byte from RAM So, "main" switches in the RAM bank, and retrieves a byte from the buffer. These macros also define a label correctly referencing the SLOT/BANK for the name of the bank. These are, for the above... RAMBANK_BUFFER = 0 ROMBANK_DEMO = $41 Why $41? Well, because it's SLOT #1 and BANK #1. %01000001 (D7D6=1), (D5-D0=1) We can use these values to switch banks/slots instead of the value for a routine in a DEF. Sometimes this is the thing you need to do (particularly when you are copying ROM to RAM and then calling the code in RAM - in that case you want to switch in the RAMBANK value, not the DEF value). If we wanted to write a byte to the buffer, we'd need to add 512 for write-access. lda #0 sta Buffer+16 + 512 Now that's pretty awful to read. You guessed it, yes... there are macros for that. In fact, for ALL non-zero-page access (which has the requirement of +512 on write), I use macros which make it clear that (a) the variable is in non-zero-page RAM, and (b) take care of adding that "512" automatically. For ANY access to memory which uses non-zero-page RAM, I suffix the opcode with "@RAM". It seems strange at first, but here's the usage... instead of the above example, we now write... lda #0 sta@RAM Buffer+16 Likewise, if we are reading, it's lda@RAM Buffer+16 Now the "lda@RAM" macro doesn't actualy DO anything other than the straight load. But the code is readable - we know that it's RAM access, but furthermore if you get into the habit of using "@RAM" then the writes are guaranteed to work as required, and it's easy to remember. There are a bunch of these "access" macros - for X, Y and A. So, ldx@RAM etc... OK, hopefully that all makes sense. Now we move on to the wonder of the age - local variables! There's a simple macro named "VAR" which can be used after any DEF to automatically allocate a variable for use in the DEF function (or anywhere else, for that matter). The variables allocated are global in scope (for good reasons), but generally should only be used locally. Let's look at a simple definition and use of a local variable... I prefix with an underscore to make it easy to see we're dealing with a local... DEF fn VAR _ptr, 2 ; declare 2-byte zero-page local named "_ptr" lda #>Buffer sta _ptr+1 lda #<Buffer sta _ptr ldy #16 lda (_ptr),y ; retrieve byte from Buffer OK, aside from the weird "VAR" thing, this is just like any other zero page variable. We've put an address into a 2-byte zero page variable and then loaded a value via indirect,y addressing. So, where is "_ptr" actually defined, and what's good about this anyway? What's stopping some other routine "stomping" on the _ptr value. This is useless, right? Well, as it is, it's pretty unusable. It needs a few "helper" functions to make it all fit together. But first, I'm glad you asked, _ptr is allocated from a "pool" of zero page variables, known as the overlay area (or local variable area). Functions are allocated memory in this "pool" for their own use. This memory is GUARANTEED to be not-stomped on by functions that you may call and you are GUARANTEED not to be stomping on local variables of functions that called YOU. It has two helper-macros - REF and VEND Let's have a look... DEF fn2 VAR _Buffer, 16 ; delcare 16-byte buffer VEND fn2 ; fill _Buffer with something rts DEF fn VAR _val, 1 ; declare 1-byte variable VEND fn ; end of variables for fn lda #10 sta _val jsr fn2 ; _val is STILL 10 - GUARANTEED Now let's get our head around this. There's a function named "fn2" which declares a 16-byte buffer which it fills with (say) random values. And that function is called by "fn" which declares its own local variable "_val" into which it writes 10 before calling "fn2". The comment says that "_val" is still 10 - GUARANTEED. How is this so, when both _Buffer and _val share the same "local variable" block of memory. Well, the trick is - you guessed it - another macro. More specifically, we want to guarantee that _val does not overlap any other local variable in any function that calls "fn" or any function that "fn" calls. Or any function that fn2 calls... or any function that the function that fn2 calls... calls. Turtles all the way down. Now, this is going to take some getting used to, but if you do this for ALL your functions, you benefit from getting the assembler doing ALL the work for you and calculating addresses for the local variables which are GUARANTEED not to overlap or clash. And once you realise the power of using overlay ("local") variables, you'll suddenly start thinking about how FEW variables you really need - and furthermore you can name them all with meaningful names. You could, for example, have dozens/hundreds of zero page variables which all - because of their timing/usage - can share a 10-byte zero page buffer. So, the bit I suspect many will not like, but here goes... the REF macro (for reference). Whenever you call a function from anywhere, you need to go to the DEF of that function and add a REF to the calling function. So I kind of lied - the above code will NOT generate unique addresses for the variables, we need to add that REF. The above code would be rewritten like this... DEF fn2 REF fn VAR _Buffer, 16 ; delcare 16-byte buffer VEND fn2 ; fill _Buffer with something rts DEF fn VAR _val, 1 ; declare 1-byte variable VEND fn ; end of variables for fn lda #10 sta _val jsr fn2 ; _val is STILL 10 - GUARANTEED All that's changed here is we've added "REF fn" in the declaration block of the top function. It's basically saying "hey, this function is called by the function named 'fn'". That's useful because it now gives the VAR macro enough information to determine where the true local variables can start. But how does this all work, when we have references to references to references and you can't know what the value might be because you haven't assembled other parts that will affect the location, etc? Well, fortunately, we let dasm worry about that. Basically, dasm will do "another pass" when it sees a value change. So the macros for each function set the end address of the variable block for that function (that's what the VEND macro does). The REF macro will calculate the last used memory address in ALL of the REF statements for any DEF function, and then set the declared variable's value, which in turn sets the functions VEND value. If that VEND value is thus changed from any earlier value it has, then dasm will do another pass and after several/many passes, ALL of the references/variables will be correct and consistent and we have that GUARANTEE that our local variables/overlays will not stomp on each other. But, as noted, it relies on you being meticulous about using those REFs. Here's an actual example from Chess... DEF GenerateAllMoves REF ListPlayerMoves REF aiComputerMove REF quiesce REF negaMax VAR __vector, 2 VAR __pieceFilter, 1 VEND GenerateAllMoves ; code.... rts So, you see there are 4 references (other functions that call "GenerateAllMoves") and two local variable declarations. It's all self-organising, and now I can delcare local variables IN the function that actually uses them. Very nice. But, I'm glad you asked, what if you want to share variables between functions? What if you wanted to calculate something in a subroutine and "pass" the result back to the caller? Doesn't this method stop me from using a local buffer/overlay to do that? Because the whole setup is designed to STOP the stomping/sharing. For example, if I had something like this... DEF fn REF fn2 VAR _temp, 1 VAR _param, 3 ; code to put stuff into "_param" to return to caller rts DEF fn2 VAR _stuff, 10 ; uses 10 bytes for something jsr fn ; what's in _param???? ; what's in _stuff??? has it been stomped? In this case, all is OK because fn knows that it is referred to by "fn2" and so the VAR declaration will place _temp and _param AFTER _stuff's 10 bytes. No stomping. But it's unsafe because there is no clear indication that "fn2" actually uses/requires the data in "_param" as a return value. So, here's the "proper" way to share local variables"... DEF params VAR _param, 3 VEND params DEF fn2 REF params ; uses the above local variables! REF fn2 VAR _temp1, 1 VEND fn2 ; code,including writing to _param ; rts DEF fn REF params ; ALSO uses the params local block! VAR _stuff, 10 VEND fn ; code as before Hope that's clear. We've defined an independent local variable block - with no code - and then in each function that shares that block we simply put a reference (REF). The auto-calculation code will make sure (yes, GUARANTEE) that the variables don't clash. In particular, if our overlay area started at the beginning of zero page ($80) then our variables would be allocated like this _param = $80 ; 3 bytes long _stuff = $83 ; 10 bytes long _temp1 = $8D ; 1 byte long We have no "stomping" and the variables are "intelligently" allocated. It's magic. Putting it all together, here's the basic overlay of how it goes... SLOT 1 ROMBANK One DEF fn VAR _temp, 1 VEND fn lda #3 sta _temp CALL fnX ; in slot 2 ; "sees" _shared == 9 ; "_temp" is still 3 rts SLOT 2 ROMBANK Two DEF fnX REF fn VAR _fxtemp, 1 VEND fnX lda #9 sta _shared rts DEF sharedVar VAR _shared,1 VEND sharedVar Some habits I've picked up... I suffix all subroutine calls with the slot number as a comment, as it makes it easier to double-check stuff. Now this is purely personal preference, but here's what it looks like... lda #BANK_PIECE_VECTOR_BANK sta SET_BANK;@2 In other words, just by looking at that, I can see that it's loading into slot #2 So, that's how I've set myself up to program 3E+ games. I really really like this bankswitch scheme and in particular these support macros. I can simply cut/paste a subroutine from one bank to another and the assembler will not only work out all the references for me, but also tell me if that results in an incompatible bank call somewhere. I honestly don't expect anyone to take the time to review/learn this stuff - but now it's documented so hopefully it will give someone else some ideas. Here are my macros... there are a few missing init/equates, but if anyone tries to use this I'm sure it will either be easy to work out or I'm happy to answer questions. ; MACROS.asm ;--------------------------------------------------------------------------------------------------- MAC DEF ; {name of subroutine} ; Declare a subroutine ; Sets up a whole lot of helper stuff ; slot and bank equates ; local variable setup SLOT_{1} SET _BANK_SLOT BANK_{1} SET SLOT_{1} + _CURRENT_BANK ; bank in which this subroutine resides {1} ; entry point TEMPORARY_VAR SET Overlay TEMPORARY_OFFSET SET 0 VAR_BOUNDARY_{1} SET TEMPORARY_OFFSET _FUNCTION_NAME SETSTR {1} ENDM ;--------------------------------------------------------------------------------------------------- MAC RAMDEF ; {name of subroutine} ; Just an alternate name for "DEF" that makes it clear the subroutine is in RAM DEF {1} ENDM ;--------------------------------------------------------------------------------------------------- MAC SLOT ; {1} IF ({1} < 0) || ({1} > 3) ECHO "Illegal bank address/segment location", {1} ERR ENDIF _BANK_ADDRESS_ORIGIN SET $F000 + ({1} * _ROM_BANK_SIZE) _BANK_SLOT SET {1} * 64 ; D7/D6 selector ENDM ;--------------------------------------------------------------------------------------------------- ; Temporary local variables ; usage: ; ; DEF fna ; REF fnc ; REF fnd ; VAR localVar1,1 ; VAR ptr,2 ; VEND fna ; ; The above declares a functino named 'fna' ; The function declares two local variables, 'localVar1' (1 byte) and 'ptr' (2 bytes) ; These variables are given an address in the overlay area which does NOT overlap any of ; the local variables which are declared in the referring functions 'fnc' and 'fnd' ; Although the local variables are available to other functions (i.e., global in scope), care ; should be taken NOT to use them in other functions unless absolutely necessary and required. ; To share local variables between functions, they should be (re)declared in both so that they ; have exactly the same addresses. ; The relative offset into the overlay area for the next variable declaration... TEMPORARY_OFFSET SET 0 ; Finalise the declaration block for local variables ; {1} = name of the function for which this block is defined MAC VEND ; register the end of variables for this function VAREND_{1} = TEMPORARY_VAR ;V2_._FUNCTION_NAME = TEMPORARY_VAR ENDM ; Note a reference to this function by an external function ; The external function's VEND block is used to guarantee that variables for ; the function we are declaring will start AFTER all other variables in all referencing blocks MAC REF ; {1} IF VAREND_{1} > TEMPORARY_VAR TEMPORARY_VAR SET VAREND_{1} ENDIF ENDM ; Define a temporary variable for use in a subroutine ; Will allocate appropriate bytes, and also check for overflow of the available overlay buffer MAC VAR ; { name, size } ; ;LIST OFF {1} = TEMPORARY_VAR TEMPORARY_VAR SET TEMPORARY_VAR + TEMPORARY_OFFSET + {2} OVERLAY_DELTA SET TEMPORARY_VAR - Overlay IF OVERLAY_DELTA > MAXIMUM_REQUIRED_OVERLAY_SIZE MAXIMUM_REQUIRED_OVERLAY_SIZE SET OVERLAY_DELTA ENDIF IF OVERLAY_DELTA + Overlay >= TOP_OF_STACK LIST ON VNAME SETSTR {1} ECHO "Temporary Variable", VNAME, "overflow!" ERR ECHO "Temporary Variable overlow!" ENDIF LIST ON ENDM MAC ROMBANK ; bank name SEG ROM_{1} ORG _ORIGIN RORG _BANK_ADDRESS_ORIGIN _BANK_START SET * {1}_START SET * _CURRENT_BANK SET (_ORIGIN - _FIRST_BANK ) / _ROM_BANK_SIZE ROMBANK_{1} SET _BANK_SLOT + _CURRENT_BANK _ORIGIN SET _ORIGIN + _ROM_BANK_SIZE _LAST_BANK SETSTR {1} ENDM ;--------------------------------------------------------------------------------------------------- MAC CHECK_BANK_SIZE .TEMP = * - _BANK_START ECHO _LAST_BANK, "SIZE =", .TEMP, ", FREE=", _ROM_BANK_SIZE - .TEMP IF ( .TEMP ) > _ROM_BANK_SIZE ECHO "BANK OVERFLOW @", _LAST_BANK, " size=", * - ORIGIN ERR ENDIF ENDM ;--------------------------------------------------------------------------------------------------- MAC CHECK_RAM_BANK_SIZE .TEMP = * - _BANK_START ECHO _LAST_BANK, "SIZE =", .TEMP, ", FREE=", _RAM_BANK_SIZE - .TEMP IF ( .TEMP ) > _RAM_BANK_SIZE ECHO "BANK OVERFLOW @", _LAST_BANK, " size=", * - ORIGIN ERR ENDIF ENDM ;--------------------------------------------------------------------------------------------------- MAC RAMBANK ; {bank name} SEG.U RAM_{1} ORG ORIGIN_RAM RORG _BANK_ADDRESS_ORIGIN _BANK_START SET * _CURRENT_RAMBANK SET (ORIGIN_RAM / _RAM_BANK_SIZE) RAMBANK_{1} SET _BANK_SLOT + _CURRENT_RAMBANK ORIGIN_RAM SET ORIGIN_RAM + _RAM_BANK_SIZE _LAST_BANK SETSTR {1} ENDM ;--------------------------------------------------------------------------------------------------- ; Failsafe call of function in another bank ; This will check the slot #s for current, call to make sure they're not the same! MAC CALL ; function name IF SLOT_{1} == _BANK_SLOT FNAME SETSTR {1} ECHO "" ECHO "ERROR: Incompatible slot for call to function", FNAME ECHO "Cannot switch bank in use for ", FNAME ERR ENDIF lda #BANK_{1} sta SET_BANK jsr {1} ENDM MAC JUMP ; function name IF SLOT_{1} == _BANK_SLOT FNAME SETSTR {1} ECHO "" ECHO "ERROR: Incompatible slot for jump to function", FNAME ECHO "Cannot switch bank in use for ", FNAME ERR ENDIF lda #BANK_{1} sta SET_BANK jsr {1} ENDM ;--------------------------------------------------------------------------------------------------- ; RAM accessor macros ; ALL RAM usage (reads and writes) should use these ; They automate the write offset address addition, and make it clear what memory is being accessed MAC sta@RAM ;{} sta [RAM]+{0} ENDM MAC stx@RAM ;{} stx [RAM]+{0} ENDM MAC sty@RAM ;{} sty [RAM]+{0} ENDM MAC lda@RAM ;{} lda {0} ENDM MAC ldx@RAM ;{} ldx {0} ENDM MAC ldy@RAM ;{} ldy {0} ENDM MAC adc@RAM ;{} lda {0} ENDM MAC sbc@RAM ;{} lda {0} ENDM MAC cmp@RAM ;{} cmp {0} ENDM ;--------------------------------------------------------------------------------------------------- ;EOF Edited June 11, 2020 by Andrew Davie 6 2 Link to comment Share on other sites More sharing options...
Recommended Posts