Hello world tutorial
This tutorial will guide you through all the necessary steps to produce a burnable NeoGeo romset which displays the text "Hello world !" (or something close). It is written for Windows but all the necessary tools have equivalents for MacOS and Linux.
The programming language used is 68k assembly. Good knowledge of it is not needed as all instructions will be detailed.
Setting up a minimal development environment
- Download the latest MAME binaries from [mamedev.org]. Extract the zip contents to a folder such as "C:/MAME/".
- To run NeoGeo software with MAME, you will need the dumps of the various embedded ROMs which are part of the system. You can find them as a pack called neogeo.zip (various forms, more or less complete). The files needed are 000-lo.lo (64KiB, the LO ROM), sm1.sm1 (128KiB, the M1 ROM for sound), sp-s2.sp1 (128KiB, the system ROM) and sfix.sfix (128KiB, the SFIX ROM). Copy those 4 files to the "C:/MAME/roms/neogeo/" folder (create roms/ and neogeo/ if needed). Like original games these ROMs are copyrighted, so you won't find them here.
- As a base, find and download a Super Sidekicks romset (ssideki.zip) and extract it to "C:/MAME/roms/ssideki/". Make sure that the following files are present and that they are named correctly (MAME is picky about file names and extensions):
- Write a short batch file to start MAME easily with the right parameters. Create a new text file and add these lines:
mame -debug -nofilter -window ssideki pause
Save it with as runmvs.bat in "C:/MAME/" and run it. MAME should open the emulation window and its debugger. Don't be scared, just highlight the debugger window and press F5 (run) to see if Super Sidekicks starts in the main window. If the romset is bad, the missing and/or invalid files will be shown in the command line prompt. When the game runs correctly, close the debugger and the emulation window.
- Download [the macro assembler AS (ASW)] and File:Flip pad.zip. There is no installation required, just extract the files to a folder like "C:/neogeo/asw/".
- For the purpose of this tutorial, Notepad can be used as a code editor. Make a folder for this project such as "C:/neogeo/helloworld/".
- Write a batch file in this folder to make ASW assemble your code and generator the P ROM. Create a new text file with the following contents:
@echo off c:\neogeo\asw\bin\asw main -L -quiet c:\neogeo\asw\bin\p2bin main -r $000000-$01FFFF c:\neogeo\asw\flip main.bin 052-p1.p1 c:\neogeo\asw\pad 052-p1.p1 524288 255 copy 052-p1.p1 c:\mame\roms\ssideki\052-p1.p1 pause
And save it as "make.bat" in the "C:/neogeo/helloworld/" folder.
Here's what happens: ASW assembles your source file, p2bin makes the P ROM with the right size ($1FFFF = 128KiB), flip.exe flips bytes in pairs (byteswapping) because P ROMs have to be that way, pad.exe fills up the P ROM with "$FF" bytes up to 512KiB to match the original ssideki P ROM size to make MAME happy, and it's finally copied in place of the origina P ROM in MAME's directory.
Be aware that in this example, only the P ROM is replaced. All graphics, musics and sounds will still be those from Super Sidekicks.
Now that you have everything to assemble and run the ROM, you can start coding.
- Go to the 68k ASM defines page, copy everything, paste it in a new text file and save it as "regdefs.asm" in "c:/neogeo/helloworld/". This allows you to use register names in place of their addresses (easier to remember). For example, instead of writing $300001 in your code, you'll be able to write REG_DIPSW instead and ASW will take care of translating that to $300001 thanks to this new file.
- Create a new text file and add the following lines:
cpu 68000 supmode on INCLUDE "regdefs.asm"
These aren't assembly instructions but directives that only ASW will understand. cpu 68000 indicates what CPU we're writing code for (Motorola 68000), supmode on indicates that the code will run in supervisor mode (allows us to use special instructions, the NeoGeo always runs in supervisor mode), and INCLUDE tells ASW to take a look in our previous "regdefs.asm" file. Save this file as "main.asm" in "c:/neogeo/helloworld/".
For the "cartridge" to be recognized by the system ROM and launched, header data must be present. Copy the minimal header to a new "header.asm" file.
Add the following line to main.asm:
This will also set up the v-blank interrupt vector to a label called VBLANK.
A bit of thinking
To display the "Hello world !" text, there are basically two methods:
To avoid the need to edit the sprite ROMs and draw a font, we will use the fix font that is already stored in the ssideki's S ROM (052-s1.bin).
If you look at the contents of a typical S ROM (like on this page), you will see that the first bank of tiles (256 tiles) contains a 8x8 pixels character set, which is often customized for each game. The Super Sidekicks one is stylized with color gradients, so we will use the 8x16 pixels alphabet instead, which is simpler (2 colors). It is found in the next two tile banks (tiles $100 to $2FF).
As shown in the SFIX tileset reference, bank 1 (tiles $100 to $1FF) contains the upper tiles (the top of the letters), and bank 2 (tiles $200 to $2FF) contains the lower tiles (the bottom of the letters).
By mapping those tiles correctly on the fix map, text can be displayed.
Note that there is a named MESS_OUT which purpose is to simplify text writing to the fix layer. For the purpose of this tutorial we'll do it the hard way, without using this call.
First of all, we need to define a v-blank interrupt handler. The header file indicates that a label called VBLANK must be used.
In main.asm, add the following lines:
ORG $300 VBLANK: ; Label defined in header.asm btst #7,BIOS_SYSTEM_MODE ; Check if the system ROM wants to take care of the interrupt bne .getvbl ; No: jump to .getvbl jmp SYS_INT1 ; Yes: jump to system ROM .getvbl: move.w #4,REG_IRQACK ; Acknowledge v-blank interrupt move.b d0,REG_DIPSW ; Kick watchdog rte ; Return from interrupt
The 'ORG $300 line is also an ASW directive. It tells ASW to put the following code at address $300 in our ROM. This avoids overwriting the header (which is between $0 and $200).
SNK required games to check the BIOS_SYSTEM_MODE variable to see if control has to be given to the system ROM, or if the game itself can use it.
- btst #7,BIOS_SYSTEM_MODE (Bit TeST) checks bit 7 of BIOS_SYSTEM_MODE.
- bne .getvbl (Branch if Not Equal) jumps to .getvbl if the previously checked bit wasn't 0.
- jmp BIOSF_SYSTEM_INT1 (JuMP) can be guessed quite easily.
- move.w #4,REG_IRQACK writes 4 as a word (16 bits) in REG_IRQACK.
- move.b d0,REG_DIPSW writes the D0 register's value as a byte in REG_DIPSW. This prevents the watchdog reset.
Our v-blank handler is the simplest there could be. Don't forget that interrupts can happen at any time during normal program flow. If registers have to be used in the handler routine, care must be taken to save and restore them (see MOVEM instruction). We're only reading D0 in our case, so we don't need to preserve anything.
The system ROM doesn't just jump to the game's code once and for all. It jumps to code at $122, which is also defined in the header.asm file.
At $122, there is just enough room for another jump:
So we need to define the USER subroutine.
In main.asm, add the following lines:
JT_USER: dc.l StartupInit ; Jump table for the different things the system ROM can ask for dc.l EyeCatcher dc.l Game dc.l Title USER: move.b d0,REG_DIPSW ; Kick watchdog clr.l d0 ; Clear register D0 move.b BIOS_USER_REQUEST,d0 ; Put BIOS_USER_REQUEST (byte) in D0 lsl.b #2,d0 ; D0 <<= 2 lea JT_USER,a0 ; Put the address of JT_USER in A0 movea.l (a0,d0),a0 ; Read from jump table jsr (a0) ; Jump to label jmp SYS_RETURN ; Tell the system ROM that we're done
When the system ROM calls the USER subroutine, it indicates what needs to be done with the BIOS_USER_REQUEST variable. There are 4 possible values, so we specify 4 labels as longword (32 bits) addresses (dc.l is yet another ASW directive).
The code for the USER subroutine starts after the USER label. First, the watchdog is kicked, just in case.
- clr.l d0 (CLeaR) clears the whole D0 register (32 bits).
- move.b BIOS_USER_REQUEST,d0 loads the byte variable BIOS_USER_REQUEST in D0. Since D0 was cleared, we made sure that D0 is now 000000XX.
- lsl.b #2,d0 (Logical Shift Left) shifts D0 left 2 bits, multiplying D0 by 4 (our jump table entries are 32 bits = 4 bytes long).
- lea JT_User,a0 (Load Effective Address) simply puts the JT_USER label's address in A0.
- movea.l (a0,d0),a0 loads the longword value at (A0+D0) in A0, effectively reading the jump table at the index specified in BIOS_USER_REQUEST.
- jsr (a0) jumps to the freshly loaded label address.
We now have our USER subroutine defined... And 4 new labels to define. Luckily for us, since we're not making a real game, we can only use one to simply run our code. We'll say that StartupInit, EyeCatcher and Title don't do anything:
StartupInit: EyeCatcher: Title: rts
This way, all 3 labels will resolve to the same address and point to a single RTS instruction (ReTurn from Subroutine). Normally, these subroutines are used to initialize backup RAM and to allow the system ROM to control the attract mode. We don't need that in our case.
The 4th label Game is the one we'll use to run our code.
Game: ; Label defined in the jump table lea $10F300,sp ; Init stack pointer move.b d0,REG_DIPSW ; Kick watchdog move.w #$0000,REG_LSPCMODE ; Make sur the pixel timer is disabled move.w #7,REG_IRQACK ; Clear all interrupts move.w #$2000,sr ; Enable interrupts
SP is a special 68k register (Stack Pointer). It should already be set to $10F300 by the system ROM, but we make sure it is. $10F300 is right at the end of user RAM. The stack will grow downwards. SR is also a special 68k register (System Register). Writing $2000 to it enables all interrupt levels.
Now that interrupts are ready to run, let's clear the user RAM, just for good measure.
move.l #($F300/32)-1,d7 ; We'll clear $F300 bytes of user RAM by writing 8 longwords (32 bytes) at a time lea RAMSTART,a0 ; Start at the beginning of user RAM moveq.l #0,d0 ; Clear it with 0's .clear_ram: move.l d0,(a0)+ ; Write the 8 longwords, incrementing A0 each time move.l d0,(a0)+ move.l d0,(a0)+ move.l d0,(a0)+ move.l d0,(a0)+ move.l d0,(a0)+ move.l d0,(a0)+ move.l d0,(a0)+ dbra d7,.clear_ram ; Are we done ? No: jump back to .clear_ram
User RAM (work RAM available for the game) starts at RAMSTART and is $F300 bytes long. The clearing loop could just write a 0 byte at each address, but why not use the concept of "unrolled loop", for speed ? Instead of writing one byte at a time, we can write 32 bytes for example. This makes clearing faster since there will be 32 times less iterations of the loop (that DBRA instruction takes some time).
- move.l #($F300/32)-1,d7 sets the iterations count. ASW is able to evaluate "($F300/32)-1" to $797.
- move.l d0,(a0)+ writes D0 as a longword at the address pointed to by A0. A0 is then incremented by 4 (longword = 4 bytes).
- dbra d7,.clear_ram decrements D7 and jumps to .clear_ram if it isn't zero. The count must be 1 less because DBRA branches one last time when D7 = 0.
Using D7 for the iteration count isn't required, it's just a habit.
Cleaning up the display
Before writing tile numbers to the fix map, we want to make sure the display is cleared. There are system ROM calls which clear the fix map and sprites, but again, for this tutorial we'll do it the hard way.
Clearing sprites can simply be done by setting all the sprites height attributes to 0. That way, they will be ignored for rendering.
move.w #SCB3,REG_VRAMADDR ; Height attributes are in VRAM at Sprite Control Bank 3 clr.w d0 move.w #1,REG_VRAMMOD ; Set the VRAM address auto-increment value move.l #512-1,d7 ; Clear all 512 sprites nop .clearspr: move.w d0,REG_VRAMRW ; Write to VRAM nop ; Wait a bit... nop dbra d7,.clearspr ; Are we done ? No: jump back to .clearspr
The NeoGeo should only be able to "see" the first 381 possible sprites, but clearing everything up to 512 isn't harmful. The NOP instructions are required because VRAM access can't be done at full speed. This is very important.
Then, the fix map is cleared:
move.l #(40*32)-1,d7 ; Clear the whole map move.w #FIXMAP,REG_VRAMADDR move.w #$0120,d0 ; Use tile $FF .clearfix: move.w d0,REG_VRAMRW ; Write to VRAM nop ; Wait a bit... nop dbra d7,.clearfix ; Are we done ? No: jump back to .clearfix
Keep in mind that the VRAM is accessed only by words, and that the fix map also holds the palette number for each tile. So we need to write $0120 (palette 0, tile $120).
The tile $120 in Super Sidekicks is filled with color 2.
Setting up colors
Now that the display is cleared, the colors need to be set up. We'll only use the first 3 color entries of palette 0. Color 0 is always transparent, color 1 is the text, color 2 is the background.
move.w #BLACK,PALETTES ; Transparency, color 0 of palette 0 must always be black anyways move.w #WHITE,PALETTES+2 ; Text color move.w #BLUE,PALETTES+4 ; Background
Each color entry is a word, so +2 each time.
Note that palette access can be done at any time, but short glitches can appear if done during active display. It's a good idea to only access the palette RAM during blanking.
Writing the text
The text can finally be written to the fix map.
Like palette access, VRAM access doesn't have to be done in the v-blank interval. We don't have to wait for it.
lea Text,a0 ; Load the text's address in A0 move.w #FIXMAP+(15*32)+13,REG_VRAMADDR ; Set the text position (address in fix map) move.w #$0100,d0 ; Upper tiles are in S ROM bank 1 nop move.w #32,REG_VRAMMOD ; Set the VRAM address auto-increment value .writeupper: move.b (a0)+,d0 ; Load D0's lower byte tst.b d0 ; See if that was a 00 byte beq .doneupper ; It was: end of text, jump to .doneupper move.w d0,REG_VRAMRW ; Write to VRAM nop ; Wait a bit... bra .writeupper ; Jump back to .writeupper .doneupper:
- move.w #FIXMAP+(10*32)+8,REG_VRAMADDR uses ASW's expression evaluator again to make things simpler. FIXMAP is the start of the fix map, (15*32) specifies the column since the fix map is 32 tiles high, and +13 specified the line. Don't forget that the fix map is "scanned" top to bottom, and left to right.
- move.w #32,REG_VRAMMOD tells LSPC to increment the VRAM address by 32 each time we write to it. This allows us to write the text column by column.
- move.w #$0100,d0 sets D0 to $0100. The upper byte is $01 (upper tiles) and won't change.
- tst.b d0 checks if D0 is zero. If so, the end of the text is reached and the next BEQ instruction will jump out of the loop.
Note that there's only one NOP in the writing loop. Since there are more instructions between two writes, there's no need to wait that much now.
The top row of tiles is written, now let's do the bottom one:
lea Text,a0 ; Load the text's address in A0 move.w #FIXMAP+(15*32)+14,REG_VRAMADDR ; Set the text position (address in fix map) move.w #$0200,d0 ; Lower tiles are in S ROM bank 2 nop move.w #32,REG_VRAMMOD ; Set the VRAM address auto-increment value .writelower: move.b (a0)+,d0 ; Load D0's lower byte tst.b d0 ; See if that was a 00 byte beq .donelower ; It was: end of text, jump to .doneupper move.w d0,REG_VRAMRW ; Write to VRAM nop ; Wait a bit... bra .writelower ; Jump back to .writeupper .donelower:
What's the difference ? Pay attention to the initial VRAM address and the value of D0. We're now starting to write one line below (+14 instead of +13), and we're now using S ROM bank 2 (lower tiles).
Note that setting REG_VRAMMOD again isn't required. The value didn't change since last time it was set.
Also note that due to the similarity of both loops, they could be made as a single subroutine with the VRAM address and tile bank as parameters.
Note that the last 0 byte must be specified. ASW doesn't add it automatically after a quoted string.
Our program is almost complete, it just needs to halt in an infinite loop:
.loop: bra .loop
Or else the program counter will continue out of space and bad things will happen.
And the text needs to be defined:
Text: dc.b "ARUZE SUXX !!", 0
Run it !
Run make.bat, then runmvs.bat .
Have fun playing with values, colors, VRAM addresses, different font sets...