Skip to content
Lesson 2.3Following Instructions and Addresses

How Assembly Describes a Running Game

Understand what assembly is, how the CPU executes it, and how registers, memory, flags, stacks, and calls fit together.

Lesson 2.3
Read or listenPrint chapter or book
About 30 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 2.3Key ideas, reference and questions

A one-page summary of “How Assembly Describes a Running Game” you can keep beside your notes. Plain Markdown.

Lesson 2.3 — How Assembly Describes a Running Game: cheatsheet

  • Remember: Read each instruction as a plain sentence: what is read, what is written, and how many bytes. Skip memorizing opcodes.
  • Also useful: Nested returns unwind one call at a time. ret uses the top return address; releasing a stack slot does not erase its bytes.

Key terms

  • eip: eip is the instruction pointer.
  • instruction pointer: eip is the instruction pointer.
  • rcx: Without brackets, rcx is a number.
  • eflags: eflags is a register containing many one-bit results.
  • eax: If eax is compared, trace backward to the instruction that last wrote eax.
  • basic block: A straight sequence with one entry and one exit is often called a basic block.

Formulas and patterns

  • jb (unsigned): is 100 below 4,294,967,295? yes -> refuse the purchase; jl (signed): is 100 less than -1? no -> allow it, then subtract; -1, which adds a gold

Review

  • Q: In mov eax, [rcx+8], what do the brackets mean? A: Read the value stored in memory at address rcx plus 8
  • Q: A cmp instruction compares a field with eax, and you want to know where that eax value came from. What should you look for? A: The last instruction before it that wrote eax
Download cheatsheet (.md)

For an unfamiliar operation, use the instruction reference: it explains operand forms, changed values and flags, with an adjustable worked trace for each instruction.

A CPU does not run source code or even the words mov and sub. It runs machine code: bytes whose bit patterns mean operations for one CPU architecture. Continue the 100-to-75 gold purchase from Lesson 2.1: this first instruction loads its price, 25, into a register. Later instructions will compare and subtract it.

Assembly language is a human-readable spelling of those operations. For 32-bit x86, these five bytes:

B8
19
00
00
00
opcode
the number 25, little endian
B8 means “put the 32-bit number that follows into eax.” The four bytes after it are that number.

can be shown as:

mov eax, 25

The byte B8 selects one form of mov. The next four bytes hold 25 as a little-endian 32-bit number. The readable instruction and the bytes describe the same CPU action at different levels.

compiler
Windows maps them
into memory
disassembler
assembler
source code
machine-code bytes
in the EXE
the CPU executes them
assembly text
for a human

An assembler travels from assembly text to machine code. A disassembler travels from machine code to assembly text. A debugger includes a disassembler and also shows the live register and memory values while the instructions execute.

Assembly preserves machine effects, not the author’s original explanation. A release build usually does not retain friendly variable names, comments, source loops, or the exact class boundaries that produced it. Optimization may inline two functions into one, remove a variable completely, or replace multiplication with a different but equivalent instruction sequence.

Therefore, disassembly shows what the machine does; it is not recovered source code. A decompiler can suggest source-like structure, but you still verify its types, signs, widths, branches, and calling convention against the actual instructions.

This book’s historical game builds are 32-bit x86 programs. Their main registers are named eax, ebx, ecx, edx, esi, edi, esp, ebp, and eip.

A 64-bit x86 program uses wider names such as rax and rip. ARM uses a different instruction set and different register names. Assembly is not one universal language; it is the readable face of a particular processor architecture.

The examples use Intel syntax, where the destination normally appears before the source:

mov eax, ebx ; copy ebx into eax

Other tools may display AT&T syntax with a different order and punctuation. Pick one syntax while learning so the notation does not hide the idea.

A debugger may show:

007CCD91 29 42 04 sub dword ptr [edx+4], eax
address007CCD91
bytes29 42 04
mnemonicsub
destinationdword ptr [edx+4]
sourceeax

Read it from left to right:

The parts of a disassembly row

Part Meaning
007CCD91 address of the first instruction byte in this run
29 42 04 the three machine-code bytes
sub mnemonic: the short human name for the operation
dword ptr [edx+4] destination: a four-byte value in memory
eax source: the four-byte value to subtract

In plain English: “Take the 32-bit number stored four bytes after the address in edx, subtract eax, and store the answer back in that same memory location.”

This one sentence is far more useful than memorizing that opcode 29 means a form of subtraction.

At a simplified level, one thread repeatedly:

  1. uses eip to find the next instruction;
  2. fetches its bytes from executable memory;
  3. decodes what the bytes mean;
  4. reads the required registers or memory;
  5. performs the operation;
  6. writes the result;
  7. advances eip or jumps somewhere else.
fetch the bytes
at eip
decode them
read registers
or memory
do the operation
write the result
advance eip,
or jump

eip is the instruction pointer. It is not a normal game variable. It tells the CPU where this thread is executing. A jump, call, return, exception, or debugger action can change it.

On the ordinary path, eip advances by the length the decoder just found. The five bytes 29 42 04 85 C0 decode from offset 0 as sub dword ptr [edx+4], eax (3 bytes) and then test eax, eax (2 bytes), so the next instruction starts at 0 + 3 + 2 = 5.

Predict first

Suppose eip points at offset 1 instead, one byte inside the sub. What does the CPU do?

Answer: It decodes different instructions from the same bytes. The CPU keeps no list of where instructions begin. At offset 1 the byte 42 is a complete instruction, inc edx, and 04 85 is add al, 85h: real instructions the program never meant.

Each instruction’s length sets where the next one startseip moves past the bytes the instruction used: 0 + 3 = 3, then 3 + 2 = 5.
  1. 1 eip = 0: 3 bytes, next start 0 + 3 = 3

    Bytes 29 42 04 85 C0
    offset 0offset 4
    29 42 04sub dword ptr [edx+4], eax
    85 C0not decoded yet

    29 42 04 is one instruction, so decoding continues at offset 3.

  2. 2 eip = 3: 2 bytes, next start 3 + 2 = 5

    Bytes 29 42 04 85 C0
    offset 0offset 4
    29 42 04sub dword ptr [edx+4], eax
    85 C0test eax, eax

    85 C0 is the next instruction. The one after it starts at offset 5.

eip moves past the bytes the instruction used: 0 + 3 = 3, then 3 + 2 = 5.

Starting one byte late decodes other instructionsThe same bytes, decoded from offset 1. Each result is a real instruction; none is what the program meant.
  1. 1 eip = 1: 42 is inc edx, next start 2

    Bytes 29 42 04 85 C0
    offset 0offset 4
    29skipped
    42inc edx
    04 85 C0not decoded yet

    The byte 42 is a complete one-byte instruction.

  2. 2 eip = 2: 04 85 is add al, 85h, next start 4

    Bytes 29 42 04 85 C0
    offset 0offset 4
    29skipped
    42inc edx
    04 85add al, 85h
    C0not yet

    04 means “add the next byte to al”, so 85 becomes its number.

  3. 3 eip = 4: C0 swallows what follows

    Bytes 29 42 04 85 C0
    offset 0offset 4
    29skipped
    42inc edx
    04 85add al, 85h
    C0 …needs 2+ more bytes

    C0 starts a shift or rotate that needs at least two more bytes, so it eats the start of the next real instruction at offset 5.

The same bytes, decoded from offset 1. Each result is a real instruction; none is what the program meant.

Registers hold the CPU’s current working values

Section titled “Registers hold the CPU’s current working values”

Registers are tiny, very fast storage locations built into the CPU. On 32-bit x86, each general-purpose register below holds 32 bits:

x86 registers at a glance

Register Common historical role Important warning
eax return value and arithmetic a function may use it for anything temporary
ebx preserved general value “preserved” depends on the calling convention
ecx counter or this pointer not every use is a counter or object
edx arithmetic or extra argument meaning comes from surrounding instructions
esi / edi source/destination pointers compilers also use them as ordinary storage
esp top of the current thread’s stack unbalanced changes usually crash on return
ebp stack-frame reference in some builds optimized code may use it as a normal register
eip address of the next instruction controlled by branches, calls, and returns

The role column is a clue, not a promise. Ask what the current function actually reads and writes.

The register value 0xFFFF_FFFF is only 32 one-bits. An instruction may interpret those bits as:

  • unsigned integer 4,294,967,295;
  • signed integer -1;
  • four bytes;
  • part of a pointer;
  • a bit mask where every switch is on.
FF
FF
FF
FF
as a u32: 4,294,967,295
as an i32: −1
The bits are identical. Only the instruction that uses them chooses the reading.

Source-level types give those bits a declared meaning. In disassembly, only the instruction that uses the bits, and the code around it, tells you which meaning applies.

You do not need to memorize the whole x86 instruction set. Start with a handful of verbs, but always connect each verb to the CPU, registers, memory, and control flow described above.

The debugger changes the view, not the bytes

Section titled “The debugger changes the view, not the bytes”

The same four bytes can be displayed as hexadecimal, a signed integer, an unsigned integer, a float, four characters, or an instruction. Changing the debugger’s display format does not convert the target’s memory. It changes only the question you ask about those bits.

Use the program’s operations to choose the view:

  • a floating-point instruction supports a float interpretation;
  • sign extension or a signed conditional jump supports a signed integer;
  • a memory dereference supports an address interpretation;
  • repeated one-byte reads ending at zero support a C-string interpretation;
  • bytes that decode into valid-looking instructions are code only if control flow actually reaches them.

This last point matters because a code section can contain alignment bytes, constants, and jump tables. A disassembler can produce a plausible instruction from almost any byte sequence. What makes bytes code is that execution reaches them: a jump, call, or fall-through from other code leads there, or a debugger sees the CPU run them.

mov destination, source copies data:

mov eax, 25 ; eax now contains 25
mov ebx, eax ; ebx now contains 25

Square brackets mean “use the memory at this address”:

mov eax, [rcx] ; read 4 bytes from the address in rcx
mov [rcx], eax ; write 4 bytes to that address

Without brackets, rcx is a number. With brackets, that number is treated as an address.

For the 32-bit course games, the same examples usually use ecx instead of rcx:

mov eax, [ecx] ; read four bytes from memory
mov [ecx], eax ; write four bytes to memory
0x500064
0x500100
0x500200
0x500300
mov eax, [ecx] copies these four bytes: eax = 100
Suppose ecx holds 0x5000. mov eax, ecx copies the number 0x5000 itself. The brackets in mov eax, [ecx] go to that address and copy what is stored there.

The instruction decides the size. byte ptr means one byte, word ptr means two, dword ptr means four, and qword ptr means eight.

Little endian explains “backward” bytes

Section titled “Little endian explains “backward” bytes”

x86 stores a multi-byte integer with its least-significant byte first. The value 0x12345678 appears in memory as:

+078
+156
+234
+312
the value 0x12345678

The number is not reversed when the CPU uses it. This is only the byte order in memory. The conversion helpers u32::from_le_bytes and to_le_bytes make the chosen byte order explicit.

add eax, 10 ; eax = eax + 10
sub eax, 3 ; eax = eax - 3
inc eax ; eax = eax + 1
dec eax ; eax = eax - 1

The source-level equivalent is easier to read:

let mut value = 25_i32;
value += 10;
value -= 3;
value += 1;
value -= 1;

Starting at 25, those four operations leave value at 32: add 10, subtract 3, add 1, subtract 1. The assembly operations would do the same arithmetic if eax started at 25. The debugger lets us check the actual register value before and after each instruction instead of assuming that starting point.

cmp performs a comparison and updates flags. A jump reads those flags:

cmp eax, 0
je player_is_dead

je means “jump if equal.” Common jumps include:

Jumps after a compare

Instruction Meaning after cmp left, right
je / jz equal / zero
jne / jnz not equal / not zero
jg greater, signed
jl less, signed
ja above, unsigned
jb below, unsigned
jmp always jump

Signed and unsigned matter because the same bits can be read in different ways. 0xFFFF_FFFF is 4,294,967,295 as a u32, but -1 as an i32.

That choice decides which branch actually runs. Imagine a price of 0xFFFFFFFF arriving at a gold check while the player holds 100 gold:

jb (unsigned): is 100 below 4,294,967,295? yes -> refuse the purchase
jl (signed): is 100 less than -1? no -> allow it, then subtract
-1, which adds a gold

Identical bits, identical cmp, opposite outcomes. When you are reading a check that guards something valuable, the signedness of the jump is not a footnote — it is very often the entire bug.

The type makes that choice visible:

let signed = -1_i32;
let unsigned = u32::MAX;
assert_eq!(signed.to_ne_bytes(), unsigned.to_ne_bytes());

The assertion passes because -1_i32 and u32::MAX have the same four-byte pattern on this target. A signed jump treats that pattern as −1; an unsigned jump treats it as 4,294,967,295. The branch instruction determines which meaning controls the next step.

Flags are one-bit answers from the last operation

Section titled “Flags are one-bit answers from the last operation”

eflags is a register containing many one-bit results. Common ones include:

Flag Plain-English question it helps answer
ZF, zero flag Was the result zero?
CF, carry flag Did unsigned arithmetic carry or borrow?
SF, sign flag Is the result’s top bit set?
OF, overflow flag Did signed arithmetic overflow its range?

cmp eax, ebx behaves like a subtraction that throws away the numeric answer but keeps the flags. je, jg, ja, and the other conditional jumps interpret those flags in different combinations.

This is why signed and unsigned jumps differ even though the register bits are identical.

Here are the flags after the two comparisons in the gold check at the end of this lesson:

ZF0
CF0
SF0
OF0
Gold 100 compared with price 25: 100 − 25 = 75. The result is not zero and nothing was borrowed, so je and jb both fall through.
ZF0
CF1
SF1
OF0
Gold 20 compared with price 25: 20 − 25 needs a borrow. CF = 1 makes jb jump. SF differs from OF, which makes jl jump as well.

Lesson 1.10 develops bit masks, shifts, and rotations before we use their assembly forms. It is a useful optional detour when these operations are new to you.

call jumps into a function and remembers where to return. ret goes back.

call calculate_damage
; execution continues here after `ret`

The stack is a last-in, first-out area used for return addresses, saved registers, and some local values:

push rbx
; use rbx
pop rbx
ret

On 32-bit x86, the stack grows toward lower addresses. push eax subtracts four from esp and stores eax there. pop eax loads four bytes and adds four back to esp.

0x0019FF30older data, where esp pointed before
0x0019FF2C25, the value in eax
esp after push eax
push eax with eax = 25. The stack pointer moves down four bytes and the value lands there. pop eax reads it back and moves esp up again.

call target is roughly:

push address_of_the_next_instruction
jump target

ret roughly pops that saved address into eip. If a hook adds an unmatched push, ret may treat some unrelated value as an address and jump into nonsense.

Follow the call calculate_damage above through its return. The saved address belongs to the caller’s next instruction; it is separate from the address of calculate_damage:

Watch it happenA call saves the way back; ret uses it

A listing shows a call to calculate_damage at 0x00401020. The address of the next instruction, 0x00401025, is pushed onto a stack that grows leftward, and esp moves from 0x0019FF30 to 0x0019FF2C. The function pushes and pops ebx, then ret moves the saved address into eip, esp returns to 0x0019FF30, and execution resumes after the call.

Read the steps
  1. The caller reaches call calculate_damage at 0x00401020. Execution must enter the function, and later continue at the instruction just after this call.
  2. call pushes the address of the next instruction, 0x00401020 + 5 = 0x00401025, onto the stack. esp drops by four, from 0x0019FF30 to 0x0019FF2C. That saved address is the route back.
  3. Execution jumps to calculate_damage at 0x00401100. The function pushes ebx and later pops it, so the return address is on top again when ret runs.
  4. ret takes the saved address from the stack and puts it in eip, then adds four back to esp. An unmatched push would leave some other value where ret expects the address.
  5. Execution continues at 0x00401025, immediately after the call.

The function’s entry address tells call where to go; the saved return address tells ret where to come back.

A nested call returns to its immediate caller

Section titled “A nested call returns to its immediate caller”

An ordinary near ret does not search for “the main program.” It takes the return address currently on top of the stack. If main calls add_bonus, and add_bonus calls add_one, the first return goes to add_bonus; its return goes to main. The last-in, first-out order keeps these two continuations separate.

The labels below stand for addresses in a simplified 32-bit x86 example. Each near call saves one four-byte return address; each plain ret removes one. There are no stack arguments or saved local registers in this model. Change the starting score and watch the two slots as well as ESP.

Two calls need two return addresses. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

main:        mov eax, start
             call add_bonus
after_bonus: mov [score], eax
             jmp done
add_bonus:   call add_one
after_one:   ret
add_one:     add eax, 1
             ret
done:        nop

The result is 5. Two calls returned in reverse order, and the stack pointer recovered its starting value.

Final values: start = 4; EIP = done; ESP = 0x1000; EAX = 5; score = 5; call depth = 0.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with EAX = 4 and ESP = 0x1000. Neither return slot is active yet.

    start = 4; EIP = main; ESP = 0x1000; EAX = 4; score = not stored yet; call depth = 0.

  2. Step 2, line 2. The first call saves after_bonus at 0x0FFC, subtracts four from ESP, and enters add_bonus.

    start = 4; EIP = add_bonus; ESP = 0x0FFC; EAX = 4; score = not stored yet; call depth = 1.

    Memory: 0x0FF8 = not written yet (outside active stack); 0x0FFC = after_bonus (top of active stack).

  3. Step 3, line 5. The nested call saves after_one at 0x0FF8. It does not overwrite the outer return address.

    start = 4; EIP = add_one; ESP = 0x0FF8; EAX = 4; score = not stored yet; call depth = 2.

    Memory: 0x0FF8 = after_one (top of active stack); 0x0FFC = after_bonus (next return).

  4. Step 4, line 7. add_one adds one: EAX becomes 5. Both saved return addresses remain in their slots.

    start = 4; EIP = add_one; ESP = 0x0FF8; EAX = 5; score = not stored yet; call depth = 2.

    Memory: 0x0FF8 = after_one (top of active stack); 0x0FFC = after_bonus (next return).

  5. Step 5, line 8. The inner ret takes after_one from the top and adds four to ESP. Popping releases the slot; it does not erase its bytes.

    start = 4; EIP = after_one; ESP = 0x0FFC; EAX = 5; score = not stored yet; call depth = 1.

    Memory: 0x0FF8 = after_one (old bytes) (outside active stack); 0x0FFC = after_bonus (top of active stack).

  6. Step 6, line 6. The outer ret takes after_bonus next. Execution returns to main, and ESP is back at 0x1000.

    start = 4; EIP = after_bonus; ESP = 0x1000; EAX = 5; score = not stored yet; call depth = 0.

    Memory: 0x0FF8 = after_one (old bytes) (outside active stack); 0x0FFC = after_bonus (outside active stack).

  7. Step 7, line 3. main stores 5 to score. Returning did not undo the calculation.

    start = 4; EIP = after_bonus; ESP = 0x1000; EAX = 5; score = 5; call depth = 0.

    Memory: 0x0FF8 = after_one (old bytes) (outside active stack); 0x0FFC = after_bonus (outside active stack).

  8. Step 8, line 9. The result is 5. Two calls returned in reverse order, and the stack pointer recovered its starting value.

    start = 4; EIP = done; ESP = 0x1000; EAX = 5; score = 5; call depth = 0.

    Memory: 0x0FF8 = after_one (old bytes) (outside active stack); 0x0FFC = after_bonus (outside active stack).

The returned-to instruction is the one after the matching call, not the instruction that made the call. Also, popping a slot moves the stack pointer; it does not clear the old bytes. Those bytes alone are no longer evidence that the return address is still part of the active stack.

Call/return cheatsheet: call records where execution should resume; nested calls add newer return slots; ret uses the newest active slot. A calling convention separately specifies arguments, preserved registers, and stack requirements.

Calling conventions are agreements between functions

Section titled “Calling conventions are agreements between functions”

A calling convention answers questions such as:

  • Where are arguments placed: stack or registers?
  • Which registers must the called function preserve?
  • Who removes stack arguments?
  • Where is the return value placed?

The course’s 32-bit games commonly expose cdecl, stdcall, and thiscall boundaries. The three differ mainly in who removes the arguments from the stack after the call. cdecl leaves that job to the caller, which is also what lets a cdecl function such as printf accept a varying number of arguments—only the caller knows how many it pushed. stdcall makes the callee remove its own arguments before returning. In a typical 32-bit thiscall, ecx carries the object pointer—the value an object method treats as self or this—while other arguments use the stack, and the callee cleans up like stdcall.

Do not identify a calling convention from one register alone. Inspect how the caller prepares arguments and how the callee returns. A wrong convention can leave esp incorrect even when the function address is right.

On 64-bit Windows, ordinary integer and pointer arguments start in rcx, rdx, r8, and r9; floating-point arguments use the matching XMM argument registers. The caller also reserves stack space for the register arguments and keeps the stack correctly aligned. There is one main Windows x64 convention, so do not carry the old 32-bit cdecl versus stdcall cleanup distinction into 64-bit code.

A calling convention is more than an argument list. It is the complete border contract between caller and callee:

arguments + return location + preserved registers + stack shape + unwind rules

Read both sides of a call. Code before call explains what the caller offers; the callee’s first uses explain what it expects; code after the return shows what the caller believes survived.

This pattern appears constantly:

mov eax, [rcx+30]

Read it as: “Start at the address in rcx, move forward 0x30 bytes, then read a 32-bit value.” If rcx points to a player object, offset 0x30 might be a field such as health.

A typed struct expresses the idea by name:

#[repr(C)]
struct PlayerSnapshot {
id: u32,
team: u32,
health: u32,
}

#[repr(C)] asks the compiler for a predictable C-compatible field layout. We only rely on a layout after confirming its offsets; guessing a struct from one screenshot is not enough.

lea (“load effective address”) is the one common instruction where brackets do not mean a memory access:

mov eax, [rcx+rdx*4] ; eax = the four-byte value stored at rcx + rdx*4
lea rax, [rcx+rdx*4] ; rax = the address rcx + rdx*4 itself; nothing is read

lea rax, [rcx+rdx*4] stores the address rcx + rdx*4 itself and reads nothing; mov eax, [rcx+rdx*4] reads the four-byte value stored there into the four-byte eax (the eight-byte rax would take the next element too). A *4 scale usually steps through an array of four-byte elements, so with rdx = 2 the address is rcx+8. Compilers also use lea for plain arithmetic, so not every result is a pointer. Lesson 5.4 traces lea beside mov with addresses you can change.

rcx+010
rcx+420
rcx+830
rcx+1240
element 2
An array of four-byte numbers, with rdx = 2. lea rax, [rcx+rdx*4] puts the address rcx+8 in rax. mov eax, [rcx+rdx*4] reads the number stored there, 30.

For each instruction, ask:

  1. What is read?
  2. What is written?
  3. How many bytes are involved?
  4. Is a number signed or unsigned?
  5. Where can execution go next?

Answer those five questions one instruction at a time. A long function becomes a trail of small facts.

Reading only downward through the listing works for a tiny straight-line function. Larger functions need two connected maps.

Data flow asks where a value came from and where it goes next. If eax is compared, trace backward to the instruction that last wrote eax. If [ecx+0x30] is changed, trace how ecx obtained the suspected player address and who later reads that field.

Control flow asks which instruction can run next. A straight sequence with one entry and one exit is often called a basic block. Conditional jumps connect blocks into a graph:

yes
no
read gold
compare with price
enough?
subtract
success
failure

The graph explains the decision even when the compiler has rearranged the blocks. Source-level if, match, loops, early returns, and error paths all become control-flow edges of this kind.

Use both maps together. A branch tells you when a write occurs; data flow tells you what it changes. Guessing from only one of them is how a nearby animation counter gets mislabeled as health or a failure path gets mistaken for the normal path.

Suppose ecx points to a player and gold is the four-byte field at offset 4:

mov eax, [esp+4] ; read the price argument
cmp dword ptr [ecx+4], eax
jb cannot_afford ; unsigned gold < price
sub dword ptr [ecx+4], eax
mov eax, 1 ; return true
ret 4 ; return and remove one 4-byte stack argument
cannot_afford:
xor eax, eax ; eax = 0, meaning false
ret 4

The same listing as a control-flow graph. Each box is a basic block, and the only decision is the jb:

gold is not below the price
gold is below the price, unsigned
mov eax, [esp+4]
cmp dword ptr [ecx+4], eax
jb cannot_afford
sub dword ptr [ecx+4], eax
mov eax, 1
ret 4
cannot_afford:
xor eax, eax
ret 4

Three details in that listing puzzle almost everyone the first time.

Why [esp+4] and not [esp]? The call that reached this function pushed a return address onto the stack, so that address is now sitting at [esp]. The argument the caller pushed just before the call is one slot further in, at [esp+4]. Whenever you see a function collecting its arguments from +4, +8, +12, the saved return address is what those offsets are stepping over.

0x0019FF34the caller's own data
0x0019FF30the price argument, 25
0x0019FF2Cthe return address, pushed by call
[esp+4]
[esp]
The stack as the function begins. esp points at the return address, so the argument the caller pushed just before the call is one slot further in.

Why xor eax, eax instead of mov eax, 0? Both leave zero in eax. Any value exclusive-ORed with itself is zero, and the xor form assembles to fewer bytes, so compilers emit it constantly. Read xor register, register as “set this register to zero” and keep going.

Why does ret have a number after it? ret 4 returns and discards four bytes of arguments from the stack, so the function cleans up after itself. A plain ret leaves that job to the caller. Which form a function uses is fixed by its calling convention, and getting it wrong corrupts esp: execution returns to the correct place, and from that point on the caller’s stack is off by four bytes.

Trace it with gold = 100 and price = 25:

Step Important state
enter ecx = player, [ecx+4] = 100, [esp+4] = 25
mov eax = 25
cmp flags describe 100 - 25; unsigned-below is false
jb branch is not taken
sub [ecx+4] = 75
mov eax = 1 return value
ret 4 caller resumes and the argument is removed

Now trace gold = 20. The jb branch skips the subtraction and returns zero. That is assembly comprehension: follow state changes and rebuild the higher-level decision.

Assembly text is a readable rendering of machine bytes for one CPU architecture; x86 and ARM bytes need different decoders. In the x86 trace, eip locates the next instruction, esp marks the stack top, and eflags holds one-bit results that branches can use. Brackets such as [edx+4] mean read or write memory at the calculated address; edx+4 without brackets is only an address calculation. A call saves a return address on the stack, and ret uses it to resume the caller. The calling convention completes that contract by fixing where arguments and results go and who removes arguments from the stack. Breakpoints in the next lesson let you inspect those facts while a real instruction is paused.

Knowledge check

Read the instruction

In mov eax, [rcx+8], what do the brackets mean?

Finished Lesson 2.3?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 2: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.