This was originally posted on Google+, which has now been shut down. It was helpfully converted to Markdown by Robert Jacobson after which I adjusted it for reposting here. I have dated it at its original posting date, but it was posted here on 2019-09-07.
Let's start drawing and moving around a snake. Before we can ask the user where he wants to move the snake, we need to have a snake:
Step 9: Writing into an array
Our board
is basically a char[]
in C lingo, even though it doesn't have a type. Which brings me to typing: board
is a char[]
only because we treat it as one. This is weak typing at its finest :)
What we have to work with is the address of the start of our array. Again, if you ask the assembler, board
is just a label which translates to an address during assembling/linking. But we know better; there is an array of bytes starting at that address. We can get to the n-th byte by taking the address board + n. Simple! Note that, unlike in C, taking board + n will get us the address n bytes higher than board
, whereas in C this would depend on the type of board
.
The snake will start in the middle of the board — this is well established snake dogma — at (40, 13), so we need the address board + 40 + 13 * (width + 1) . Why (width + 1)? Because each line in board
is terminated by a newline after width bytes. Let's make a mental note that we are smudging up abstractions by keeping the game data stored in a serialized format ready for printing, before we continue by making this mistake more comfortable by introducing a constant: pitch equ width + 1.
Luckily, all the factors we need for this calculation are constants, so we can just present them to nasm for calculation. Let's shove them into the register r8:
mov r8, board + 40 + 13 * pitch
Now we have the address to the center of the board in r8. To write to this address we need the following new syntax:
mov byte [r8], 'O'
byte tells the assembler the operand size, the thing in brackets is the address to write to and the thing in quotes gets replaced by its ASCII value.
Stick these two lines in at the top of your code and marvel at how much more beautiful everything is with an O in the middle!
Your code should now look like part1.asm.
Step 10: Read and interpret
We will need an additional syscall to read from standard input. Same old, same old: man 2 read
, SYS_read
is 3
and STDIN_FILENO
is 0
. We do need a buffer to read into, and for now a one byte big buffer should do:
; In data-section
input_char db 0
; In code-section, below the write-call:
mov rax, 0x02000003 ; SYS_read
mov rdi, 0 ; filedes = STDIN_FILENO = 0
mov rsi, input_char ; buf
mov rdx, 1 ; nbyte
syscall
At this point we should absolutely check the return value, which we get in rax. For a little while, though, we can use this check:
; Assume exactly one byte was read
Nice. The byte we assume we have read is currently stuck at input_char, but we want it in a register to make it easier to work with:
mov al, [input_char] ; Almost works!
Again, to combat code size bloat due to 64 bit addresses, AMD forbade 64 bit literal addresses and introduced instruction pointer relative addressing — addressing relative to the address at which the instruction starts. For us, this means we can use mov al, [rel input_char] and have it working or we can do the recommended thing and put default rel at the start of our source file somewhere, and the rel is automatic in the mov instruction. While we're at it, let's just shove in bits 64 at the top as well, to make the file more self-describing.
The operand size is implied by using an 8 bit register.
Okay, now we need some control flow similar to what in C would look like:
if (al == 'w') {
// Move up
} else if (al == 's') {
// Move down
} // etc
The closest thing we have to work with is called conditional branching, which makes it look more like:
if (al != 'w') goto not_up;
// Move up
goto done;
not_up:
if (al != 's') goto not_down;
// Move down
goto done;
not_down:
// etc
done:
This translates to the following assembly:
cmp al, 'w' ; Compare al to 'w'
jne .not_up ; "Jump if not equal" to ".not_up"
; TODO: Move up
jmp .done ; Unconditionally jump to/goto ".done"
.not_up:
cmp al, 's' ; Compare al to 's'
jne .not_up ; "Jump if not equal" to ".not_down"
; TODO: Move down
jmp .done ; Unconditionally jump to/goto ".done"
.not_down:
; etc
.done:
In order to keep things neat and tidy, we use local labels. Labels that start with a . are local to the closest label that doesn't start with one, so if we want to, we can think of the labels as main.not_up and so on.
The cmp instruction compares its two operands by subtracting the right one from the left one and storing some facts about the result in the processor's rflags register. The different conditional branch instructions use these flags to determine if they should make the jump or not. The specific flag we are using here is the zero flag, zf. If al and 'w' are equal, subtracting them should yield zero, and when a subtraction yields zero, zf is set. In fact, jne is just an alias for the jnz instruction; Jump if not zero. There is also je and jz, which makes the jump if the operands are equal.
We want four tests for 'w', 's', 'a', 'd', for up, down, left and right, respectively. Additionally we put in a test for 'q' and make it jump to .exit if detected. You get to do all of this without further instruction.
For the "TODO: Move *"-pieces of the code, we are going to update r8 which points into the board
. For this, I am going to introduce no less than four new instructions along with their C equivalents:
; Move up:
sub r8, pitch ; r8 -= pitch
; Move down:
add r8, pitch ; r8 += pitch
; Move left:
dec r8 ; r8--
; Move right:
inc r8 ; r8++
At .done:, make it jmp .main_loop and put .main_loop: at the instruction that writes the O into the board and voilà, you have a snake-ish game-ish thing with line buffered input. Yes, you need to pump the game loop by pressing enter all the time :)
I encourage you to go and explore what happens when you step outside the board in each direction.
Your code should now look like part2.asm.
Exercise: Implement support for dying by checking if the new cell the snake walks into is empty. A good place to check is just before writing the O. If the target space is not a ' ', exit the program.
When the user enters input that does not move the snake, such as the enter key, you should avoid jumping back to .main_loop so you don't end up killing the player in those cases. Instead, just jump back to the SYS_read call.
Solution available in exercise.asm.
Next lesson: Asmtut 5: More snappy interaction