We would like to understand this snippet of assembly code from OoT 1.2, in which we change the second 3C to a 3B to make Link small for smAll dungeons shenanigans:
...
/* 00FF78 80021658 3C053C23 */ lui $a1, (0x3C23D70A >> 16)
/* 00FF7C 8002165C 34A5D70A */ ori $a1, $a1, (0x3C23D70A & 0xFFFF)
/* 00FF80 80021660 0C008572 */ jal Actor_SetScale
/* 00FF84 80021664 02002025 */ move $a0, $s0
This is in fact the code in the actor loading process that is, surprisingly enough, responsible for setting the default scale of an actor. Other actors may override this in their own initialisation functions, but as you've probably noticed, plenty just use the default scale, and so all change size at the same time.
- The original C code that produces that assembly code is
Actor_SetScale(actor, 0.01f);-
The first argument is not important in this case, it just tells the computer where to put the second argument. That second argument is a floating-point number that gives what the scale of the actor should actually be set to.
-
Floats, aka floating-point numbers, are a standard way for computers to store non-whole numbers. The representation is quite clever: the number is written as a binary expansion, with an exponent: it's like scientific notation. We can use this handy calculator to see how this works: plug 0.01f into the "float value" box: notice how it becomes a hexadecimal number, where the digits are combined from into 1, 8 and 23 bits for the sign, exponent and mantissa. (If this is confusing, just remember that a float is binary scientific notation with the exponent stored at the top, so fiddling with the 0x3C part is mostly going to just change the exponent).
-
Now we understand what the C code is doing, let's look at the assembly code the N64 is using. This is what you get when you compile the C code: only crazy people read assembly code.
The N64's memory is addressed starting at 80000000. That's why all the watches on memory addresses you set in gz start 80.
The way a processor works is it reads instructions from memory. What we're looking at here is (some of) the instructions from memory that the processor will read when an actor is spawned.
If you go to the address 80021658 in gz, you will see 3C053C2334A5D70A0C00857202002025 and so on. This is called machine code: the processor knows how to read it. It takes one set of 4 bytes at a time, and does stuff based on that.
That machine code can be translated into something that you can read (if you can read the manual), called assembly code. This is the
lui $a1, (0x3C23D70A >> 16)
ori $a1, $a1, (0x3C23D70A & 0xFFFF)
jal Actor_SetScale
move $a0, $s0
part.
The processor has some very temporary storage on it called registers. You can think of registers as like memory, but even more temporary. Using them is very fast, but there's only 32 of them, and they are only capable of storing 32 bits at a time.
One of these registers is labelled as $a1. Its special purpose is to contain the second argument of a function. If we go back and look at the C code,
Actor_SetScale(actor, 0.01f);we see that we expect $a1 to contain 0.01f, somehow.
How do we put the float into the $a1 register? An unfortunate fact about MIPS, the assembly language the N64 uses, is that it can only load 16 bits at once. Therefore we have to do this in two parts.
To load the upper 16 bits, that is, the 0x3C23, we can use the LUI instruction, aka load upper immediate (immediate is assembly language terminology for "a constant number"). This shifts the number so it fits into the top half of the register:
3C053C23 --> lui $a1, 0x3C23
after which if we looked in $a1 we would find 0x3C230000. (the last 4 numbers are obviously the number we're loading, see the language definition of lui to see how the 3C05 part splits up into the actual instruction).
The lower half of the number we can add on using the ori (or immediate ) instruction.
34A5D70A --> ori $a1, $a1, 0xD70A
After this, we end up with the full 0x3C23D70A in $a1.
It remains to discuss the last two lines. It's not strictly necessary to understand how the float load works (and how we can manipulate it), but it's interesting extra knowledge of how running assembly works
0C008572 --> jal Actor_SetScale
jal means Jump And Link. But what does that actually mean? In assembly, a function is just an address, which tells the execution to go and look somewhere else for instructions: that is, a function is essentially "just an address to go to". But after you're finished in that function, you almost certainly want to come back to where you were before execution got diverted. This is the And Link part: And Link tells the processor to save the address where it was, so it can jump back (this is what the jr $ra instruction does: it jumps to the address in the "return address" register).
What does Actor_SetScale have to do with 0C008572? Actor_SetScale is just a convenient label in the assembly code so it's easier to tell what it is: the processor will instead see a numeric address: there is no trace of the actual name in 0C008572. So what is that address?
If we look at the manual page, it tells us that the 0C part is the jal instruction (or rather, that the top 6 bits are 000011). The rest of it is the address, then. But every instruction in MIPS is 4 bytes, so there's never any need to jump to an address that isn't a multiple of 4. Therefore the address has had the bottom 2 bits removed, because we don't need them for the address. We can put them back by multiplying by 4, which gives
0x8572 * 4 = 0x21660
Ah, but this doesn't start with 80... indeed, the processor also expects the top bits to be the same as where it is jumping from, so we just add on the 80, giving
80021660
as the address to jump to.
This leaves the
move $a0, $s0
Why have we included it too? Jumping takes a long time compared to other instructions, so MIPS increases efficiency by using a delay slot: the next instruction will be run before the jump happens, even though it appears after it in the assembly. (You might think this is confusing, and you would be right, but thankfully MIPS has only a few confusing features like this: it is one of the simplest and easiest instruction sets a CPU can use; the x86 one that your desktop or laptop probably uses is a complete disaster by comparison).
This instruction is moving the contents of the $s0 register into the $a0 register: I included it because this is the actor argument. Of course you can't tell that from just this line: you'd have to go back and find where $s0 is set. Unfortunately you just find that's in a move $s0, $a0 a few lines further up, and if you keep going, you find that that $a0 is the same $a0 which is fed to the function in the first place. Thankfully therefore we don't need to worry about that one too much: we'd have te chase all over the memory to work out what that should actually be.
As a side note, now you sort of know what decomp's about: reading the assembly code and producing that much easier-to-read-and-understand C code.