Assembly
Extremis
RPG Adventure written in RISC-V Assembly
Extremis
( god, who programmed .iris to not be able to be commited? )
A text-based RPG written in RISC-V 32-bit assembly, simulated via QEMU.
"The universe decided it didn't want me doing anything better, like maybe getting a life."
Dependencies
riscv32-elf-as— RISC-V assemblerriscv32-elf-ld— RISC-V linkerqemu-riscv32— RISC-V user-mode emulator
On Arch Linux:
sudo pacman -S riscv64-elf-binutils qemu-user
Build & Run
make build # assemble and link into main.bin
make run # build + run via qemu-riscv32
make debug # run with -strace to inspect syscalls
Project Structure
Extremis/
├── main.s # Entry point (_start), calls run_chapter_1
├── src/
│ ├── include/
│ │ └── function.s # startF / endF stack frame macros
│ ├── engine/
│ │ ├── print.s # print — print a single null-terminated string (a0)
│ │ └── utils.s # printl — print a list of strings (a0=addr table, a1=count)
│ ├── chapters/
│ │ └── chapter_1.s # Chapter 1 logic; loads intro strings and calls printl
│ └── dialogue/
│ └── intro.s # String data for the intro sequence
└── build/ # Compiled .o files (mirrors src/ layout)
Architecture
The engine is pure RISC-V 32-bit assembly targeting the Linux ABI via QEMU user-mode emulation.
Stack Frame Convention
Every function uses the startF / endF macros from src/include/function.s to save and restore ra, s0, s1, s2 on the stack:
startF # push: allocate 16 bytes, save ra/s0/s1/s2
...
endF # pop: restore ra/s0/s1/s2, deallocate 16 bytes
ret
Syscalls
Warning: RARS/MARS environment call numbers do not work under
qemu-riscv32. This project uses Linux RISC-V ABI syscall numbers.
Syscall number goes in a7, invoke with ecall. Return value comes back in a0.
Output
| Intent | RARS a7 |
Linux a7 |
a0 |
a1 |
a2 |
Notes |
|---|---|---|---|---|---|---|
| Print string | 4 |
64 |
1 (stdout fd) |
buf addr | length | Linux does NOT stop at null — pass byte len |
| Print char | 11 |
64 |
1 |
char buf | 1 |
store char in memory, pass its address |
| Print integer | 1 |
— | integer | — | — | no Linux equivalent; convert to string first |
| Print hex integer | 34 |
— | integer | — | — | no Linux equivalent; convert manually |
| Print unsigned int | 36 |
— | integer | — | — | no Linux equivalent; convert manually |
Input
| Intent | RARS a7 |
Linux a7 |
a0 |
a1 |
a2 |
Notes |
|---|---|---|---|---|---|---|
| Read string | 8 |
63 |
0 (stdin fd) |
buf addr | max bytes | Linux returns raw bytes incl. newline |
| Read char | 12 |
63 |
0 |
buf addr | 1 |
returns a0 = bytes read; char is in buf |
| Read integer | 5 |
— | — | — | — | no Linux equivalent; read string, parse it |
Process
| Intent | RARS a7 |
Linux a7 |
a0 |
Notes |
|---|---|---|---|---|
| Exit | 10 |
93 |
— | RARS ignores a0; Linux reads it as exit status |
| Exit(code) | 17 |
93 |
exit code | Linux exit_group |
Memory
| Intent | RARS a7 |
Linux a7 |
a0 |
Returns | Notes |
|---|---|---|---|---|---|
| Allocate (sbrk) | 9 |
214 |
bytes (RARS) / new brk addr (Linux) | a0 = start of allocated block |
Linux brk works differently — you set the new top, not a size |
File I/O
| Intent | RARS a7 |
Linux a7 |
a0 |
a1 |
a2 |
a3 |
Notes |
|---|---|---|---|---|---|---|---|
| Open file | 13 |
56 |
dirfd (-100=CWD) |
filename addr | flags | mode | Linux uses openat |
| Read file | 14 |
63 |
fd | buf addr | max bytes | — | returns bytes read |
| Write file | 15 |
64 |
fd | buf addr | byte count | — | returns bytes written |
| Close file | 16 |
57 |
fd | — | — | — | returns 0 on success |
Time
| Intent | RARS a7 |
Linux a7 |
a0 |
a1 |
Notes |
|---|---|---|---|---|---|
| Time | 30 |
113 |
clock id (1=REALTIME) |
timespec* buf |
RARS returns split lo/hi in a0/a1; Linux writes struct to buf |
| Sleep | 32 |
115 |
clock id | timespec* |
RARS takes milliseconds in a0; Linux uses clock_nanosleep with a struct |
Adding Content
- New dialogue strings go in
src/dialogue/. - New chapters go in
src/chapters/and are.include'd into the chapter file that needs them. - The Makefile auto-discovers all
.sfiles undersrc/(excludingsrc/include/).