N64 Assembly Tutorial - Lesson 2

Updated: N64 Assembly YouTube Series

The same content updated with more explanation and showing how it's done. Twitch stream style.

https://www.youtube.com/playlist?list=PLjwOF_LvxhqTXVUdWZJEVZxEUG5qt8fsA

Lesson 2

Lets enter our first assembly code: Thanks Peter Lemon

Until we write code for it, there isn't any input or output, This is the smallest program that will run correctly on hardware. It will NOT display anything, you will need to use a Debugger to confirm it's working as expected.

Get the starter files from the github repository for this series: https://github.com/fraser125/N64_ASM_Videos

arch n64.cpu

endian msb

output "Lesson2.N64", create

fill 1052672

origin $00000000

base $80000000

include "../LIB/N64.INC"

include "N64_HEADER.ASM"

insert "../LIB/N64_BOOTCODE.BIN"

Start:

lui t0,$BFC0

addi t1,r0,8

sw t1,$7FC(t0)

Loop:

j Loop

nop

As we can see the following source code is over half assembly directives. So the first thing to do is break it down line by line.

arch n64.cpu

This is a line for the assembler so it know which assembly instructions are valid for this source file. Other options are available for the n64.rdp, n64.rsp and even for nes and snes systems.

endian msb

The assembler needs to know what format to output the instructions on disk. The N64 is Big Endian or msb, this is set by the PIF/BIOS and cannot be changed later. Note: x86, x64 are Little Endian or lsb.

output "Lesson2.N64", create

This is a handy feature of the assembler to set the Name of the output ROM file file, it also saves an extra parameter on the command line. The 'create' parameter forces the assembler to start with a new empty output file, if 'create' is not included it is assumed that the assembler is patching an existing file.

fill 1052672

Again this is a handy feature of the assembler to define the minimum size of the output file. When the N64 BIOS is running it actually copies the first 1 MB (1,048,576) of ROM into RAM starting after both the header (64 bytes) and boot code (4032 bytes). (Total 1,052,672) so it's important that the file is at least this size. Otherwise there could be garbage in that RAM area that gets executed somehow.

If the code compiles larger than this size then no fill is performed and the file will simply end when the last instruction or data is written.

If there is executable code beyond the first megabyte it will require the use of a load routine. Although most official games would make their game engine fit in the first megabyte and create routines to load level data.

origin $00000000

This is the starting position in the output file, to create a new complete ROM we will use 0.

If we were creating a patch it would be the specific location in the output file to start writing.

base $80000000

This is our first real technical bit specific to the N64. We will leave the details for later but for now it's only important to know that the assembler needs to know this value for calculating Branch (if conditions) and Jump (function call) instructions.

include "../LIB/N64.INC"

This file is written in bass assembly, although the different extension is used because this file contains commonly used constants and just a couple of macros. It's contents are merged into the source code at the point they are used. So that they can be used anywhere they should be included at the top.

While this file is included in the source at compile time, only macros and variables/constants that are used are actually in the output file.

include "N64_HEADER.ASM"

While the extension is different for this file it is also assembly code. It uses assembly directives to define the 64 header bytes of the N64 ROM. The compiled output of this file must be located at ROM Address 0 through 0x40 (64). The version in the download package is fairly well commented. Although it is very easy to use the same N64_HEADER.ASM for all projects.

insert "../LIB/N64_BOOTCODE.BIN"

Similar to the include lines above the insert will make the specified file a part of the ROM. The difference is that the file is treated as a binary blob and inserted a byte at a time. This is a good time to point out that the sequence of the include, insert statements is very important. Because these files will be compiled and written to the output file in the exact order specified.

Start:

lui t0,$BFC0

addi t1,r0,8

sw t1,$7FC(t0)

The resulting action of this 3 line start routine is required code in every N64 program. Without it your code might still work in an emulator but it will NOT work when run on hardware. This is part of the Nintendo cartridge authentication protection.

This tutorial is Lesson 2 and it's more about a minimum program to verify our environment so here are the basics about these instructions. Registers and everything else will get a lot more detail in the next couple of tutorials.

lui t0,$BFC0

Load Upper Immediate = 2 bytes of a constant loaded into the upper 2 bytes of a 4 byte register. Resulting in the value $BFC0 in the upper half of register t0 resulting in t0 = $BFC00000

addi t1,r0,8

ADD Immediate = 2 bytes of a constant added to the zero in the lower 2 bytes of a 4 byte register. Resulting in the value 8 in the register t1.

sw t1,$7FC(t0)

Store Word = Put the value from a register in to the Memory Address Space.

Put the value from t1 (8) into $BFC0 07FC.

The last uint32 position of the PIF RAM Location is $BFC0 07FC.

Loop:

j Loop

nop

In the Start block the label Start: was mostly used as a comment. In this new code the Loop: is used by the j (jump) instruction. When you don't have an operating system (Embedded devices) you will commonly see this technique, it's simply busy work for the device to run continuously. This is a very simple for(;;); or while(1); aka endless loop.

This is the first time we see the use of the delay slot for now just put a nop after every jump and branch instruction, in a later lesson Delay Slot's will be covered in more depth and there will be more productive instructions put in the delay slot.

After compiling this program use the MAME debugger to watch what this code is doing. This program will not have any output so stepping through it is the only way to know it's behaving as expected. In our N64 command prompt type the following commands.

cd c:\github\N64_ASM_Videos\Lesson02

C:\Projects_bass\N64\Lesson02>make

C:\Projects_bass\N64\Lesson02>debug Lesson2.n64

When run in debug mode an extra window opens that shows the currently executing code, the MIPS registers, a command history and a small command line. In the command window at the bottom type 'bpset 80001000'. When the system first starts it is sitting at the first BIOS / PIF instruction. Setting this breakpoint allows us to let the BIOS/PIF code run until it gets to our code.

Debugging things to try.

  • bpset 80001000 (it's interpretted as hex 0x80001000)

  • On the menu: Debug | New Memory Window

    • bfc007fc <enter>

  • F5 to Run the code until a breakpoint is hit

  • F10 to step through the code.

    • When the SW executes the value in the Memory window will change from 0000000C to 00000008

Don't worry next lesson will be more interesting with some video output. All of these same instructions will be covered in more detail as well.

Lesson 1 - Lesson 3