Jump to content

Open Club  ·  60 members

DASM
IGNORED

3E+ and macros are your friend


Andrew Davie

Recommended Posts

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 by Andrew Davie
  • Like 5
  • Thanks 2
Link to comment
Share on other sites

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...