- alex winston, august 1st, 2026
hello! this fox32 tutorial series will mainly focus on the bare metal, meaning usage of the OS will be quite limited... instead, this tutorial will mostly focus on using the ROM, accessing hardware directly, etc... the tutorial will be using the fox32asm assembler syntax. for this tutorial, instruction encoding will not be covered in detail.
this tutorial assumes you already understand binary, hexadecimal, how bitwise operations work, etc... if you don't, perhaps it's good for you to read up on how those work before reading this.
the processor has 32 32-bit general-purpose registers named r0 through r31, alongside a stack pointer rsp, an exception stack pointer resp, a frame pointer rfp, and an instruction pointer rip that points to the current instruction being executed. the processor is little-endian, and memory can be accessed in byte size (8-bit, 0..255/0xFF), half size (16-bit, 0..65535/0xFFFF), and word size (32-bit, 0..4294967295/0xFFFFFFFF).
nearly all instructions can take a size, suffixed with .8, .16, or .32. if left unspecified, the assembler will default to .32 size. some opcodes only take a .32 size, and therefore other sizes cannot be used with those opcodes.
instructions are 2 bytes minimum when assembled, and are formatted opcode<.8/.16/.32> <target>, <source>, where target and source can be one of 4 addressing modes:
additionally, if the operand is a register pointer, it can optionally be followed by an unsigned 8-bit offset: [r0+69], [r0+255]
the mov (MOVe) instruction is perhaps the simplest instruction to understand. it simply moves the source operand into the target operand:
mov r0, 1337
the above piece of code simply moves the value 1337 into register r0. of course, as mentioned before, this actually assembles to mov.32 r0, 1337 because the size was left unspecified. the result is that this instruction will assemble into 7 bytes. this is usually not a concern, as we have 64MB of memory at our disposal, but this may not be very space-efficient, especially if you engage in sizecoding activities ;). the value 1337 can fit into a half (16 bits), thus, we can rewrite it as such:
mov.16 r0, 1337
this will assemble into 5 bytes, 2 bytes less than our previous example. the saving may seem small, but these savings add up and may potentially give you space to do other things, which is important if you're writing a 512-byte program in the boot sector. you can also do the same thing with 8-bit values:
mov.8 r0, 8 ; this will only assemble to 4 bytes
it is important to note that mov moves without clearing any data that was there prior. in particular, mov.8 and mov.16 will move the operand into lower half of the register if a register is specified as the target. in other words:
this behaviour may not be desirable, especially if you are utilising the above techniques to reduce code size. to this end, there is the movz (MOVe Zero-extended) instruction. this instruction is the same as mov, but has the behaviour of clearing the upper bits (or in other words, the bits we don't care about) to 0:
and the same for movz.16:
before reading this, it is important to know that the processor has 2 internal flags (which you cannot set manually under normal circumstances): zero flag, and carry flag. zero flag is set when a math or bitwise operation results in a value of 0. the carry flag, however, is set when an arithmetic operation results in an underflow/overflow. it is a bit of a misnomer, because the carry flag acts more like a borrow flag when doing subtraction, and it is not affected by any bitwise instruction. however as all official documentation refers to it as a carry flag, it will be referred to as the carry flag for simplicity.
the add instruction will simply add the number from the source operand into the destination operand; the source operand will remain unchanged (arithmetic operations here are in hexadecimal):
the above operation can be simplified into add r0, 0x80, freeing up a register. this (and the above information) applies to sub (SUBtract), only with subtraction rather than addition.
when using arithmetic instructions, it is important to remember that the size will affect your results (i.e it will only operate on that many bits). for example:
if the resulting value is too large to fit in a 32-bit register (or too large to fit in the operation size), it will either overflow or underflow. in both cases, the carry flag will be set.
the inc (INCrement) and dec (DECrement) are specialised cases of add and sub, respectively, but their source operand is fixed to an immediate type, and only allows values of 1, 2, 4, and 8.
why would we need this? we can just write add r0, 1 and be done with it. we can of course write it that way, but the difference is in size: add r0, 8 (with the default .32 operation size) assembles into 7 bytes (smaller operation sizes mean smaller assembly, but for reasons mentioned above, may have an undesirable effect on math operations). meanwhile the equivalent inc r0, 8 only assembles into 3 bytes, a ~42.8% decrease in size, and is massively helpful in saving space.
these instructions are bitwise instructions, and require a little understanding about how boolean algebra works. the simplest instruction is bse (Bit SEt) and bcl (Bit CLear), which set and clear a bit in the target operand, with the bit to set/clear specified by the source operand:
it should be noted that immediate types for the bse and bcl instructions are always 8 bits, and will always assemble to a fixed size of 4 bytes at minimum no matter the operation size (bse.8, bse.16, bse.32 will assemble to 4 bytes at minimum). the only effect then that the operation size has on bse and bcl is that immediate values larger than the size are ineffective (bse.8 r0, 16 will not set bit 16 of r0, for example).
bse, bcl, as well as bts count bits from the lowest bit:
0101 0101
| ^--- bit 0
+----------- bit 7
the not, which performs a bitwise NOT on the target operand, which just inverts every bit in the target operand (0 -> 1 ; 1 -> 0):
1100 1010 (0xCA)
|||| ||||
VVVV VVVV (0 -> 1 ; 1 -> 0)
0011 0101 (0x35)
as always, the not instruction is affected by the operation size, so not.8 will only perform the bitwise NOT on the lower 8 bits of the operand, and not.16 only on the lower 16 bits. this also applies to every instruction in this section. it will not work on immediates, because the resulting syntax not.8 0x80 is nonsensical.
the and instruction performs a bitwise AND on the target operand with the source operand. in other words, it takes 2 bits in, and outputs a 0 if one of the inputs is 0:
1111 0101 (0xF5)
AND 0000 1111 (0x0F)
---------
0000 0101 (0x05)
the or instruction performs a bitwise OR on the target operand with the source operand. in other words, it takes 2 bits in, and outputs a 1 if any of the inputs is 1:
1111 0001 (0xF1)
OR 0000 1111 (0x0F)
---------
1111 1111 (0xFF)
the xor instruction performs a bitwise XOR (eXclusive OR) on the target operand with the source operand. in other words, it takes 2 bits in, and outputs a 1 if ONLY one of the inputs is 1 - the difference between XOR and OR is that if both inputs are 1, XOR outputs a 0:
1111 0001 (0xF1)
XOR 1010 1111 (0xAF)
---------
0101 1110 (0x5E)
there are 3 bit-shifting instructions: sla (Shift Left Arithmetic), sra (Shift Right Arithmetic) and srl (Shift Right Logical). just like bse and bcl, immediate values are fixed to 8 bits. they are basically what it says on the tin: they shift bits in the target operand by the value in the source operand. take this example of sla, that shifts the target operand left by the value in the source operand:
+----------- this bit is discarded
|
1011 0011
//// ///
|||| |||
0110 0110 <- shift a 0 into here
the sra and srl instructions are mostly the same, they shift the bits in the target operand right by the value in the source operand. the difference is sra is an arithmetic left shift, meaning the sign bit will be duplicated into the bit next to it:
compare this to srl:
(srl)
1011 0011 <- this bit is discarded
\\\\ \\\
|||\|||
0101 1001
^----------- shift a 0 into here
(sra)
1011 0011 <- this bit is discarded
|\\\ \\\
||||\|||
1101 1001
^^---------- contents of sign bit shifted in
the rol (ROtate Left) and ror (ROtate Right) instructions are functionally similar to the sla and sra/srl instruction, but the discarded bit is shifted into the upper/lower bit:
0011 0011--+
|| \||| |
\\\ \\\ |
1001 1001 |
^----------+
+----------+
| |
1011 0011 |
||| |||| |
|||/ ||| |
//// /// |
|||| ||| |
0110 0111<-+
the mul (MULtiply) and imul (Integer MULtiply) instructions are used to... well, multiply. the difference is that mul is an unsigned multiplication and imul is a signed multiplication. this has no noticeable effect on the final result, but affects how the carry flag is set.
for mul (unsigned):
compare this to imul (signed):
the div (DIVide) and idiv (Integer DIVide) instructions are also used to divide unsigned values and signed value, respectively. however, unlike mul/imul, the sign of the operation affects the final result. this also applies to rem (REMainder) and irem (Integer REMainder). unlike the rest of the math operations available, div/idiv and rem/irem do not affect the carry flag, therefore they only affect the zero flag.
for div (unsigned):
for idiv (signed):
wait! why did it return a 0? -1 / 10 should be a -0.1! this is because div/idiv throws away the fractional part, leaving only the integer part. this is important behaviour to remember, as there is no floating point support on fox32!
the rem/irem instructions are relatively simpler, as they always return an integer with no fractional part, so their result only depends on the sign of the operation.
for rem (unsigned):
for irem (signed):
note that for div/idiv and rem/irem, they will cause a division by zero exception if the divisor operand is 0!
mov r1, 20
div r1, 0 ; this will cause a division by zero exception
the stack pointer is located in the register rsp, and grows downward as values are pushed. push will push a new value onto the stack and decrement rsp. pop will pop a value off the stack and increment rsp. the amount that rsp is incremented or decremented by depends on operation size.
note: rsp does not enforce any alignment! therefore, rsp can be unaligned and values can still be pushed and popped normally.
push.8 69 ; push 1 byte onto the stack
push.16 1616 ; push 2 bytes onto the stack
push.32 76768 ; push 4 bytes onto the stack
; rsp at this point is rsp+7
pop.32 r0 ; pop 76768 into r0
pop.16 r1 ; pop 1616 into r1
pop.8 r0 ; pop 69 into r0
another behaviour to consider is that pop will not zero-extend, therefore any residual data in wherever you popped to will remain:
these instructions affect program flow, and apart from ret, they can take an absolute operand (jmp, loop, call) or they can take a relative operand that points to the instruction to jump to, indicated by an r for Relative (rjmp, rloop, rcall). relative instructions can also take an operation size (.8/.16/.32), while absolute instructions can only take a 32-bit size.
jmp (JuMP) loads the source operand into rip, the instruction pointer:
rjmp (Relative JuMP) adds the source operand into rip:
loop decrements r31, and if it is not zero, it loads the source operand into rip (the relative equivalent rloop adds the source operand to rip instead). otherwise, the loop is terminated and execution continues to the next instruction. it is quite useful in making quick and easy loops:
movz.16 r31, 16384 ; repeat 16384 times
_0:
; do something here
loop _0
the loop above will repeat 16384 times. internally, it decrements r31 first before it does the zero-check, and it will do this until the register is 0, at which point the loop terminates. therefore, it will count from 16383 down to 1.
call (and its relative counterpart rcall) is used for making subroutines and functions. when a call is encountered, the processor pushes the current rip to the stack as a 32-bit value, and loads (or adds for rcall) the source operand into rip. the old rip can be restored after the function is done with ret, which pops the last 32-bit value on the stack into rip.
call subroutine
; do some stuff here
; ...
subroutine:
; do some more stuff here
ret
you should take note to save the registers if you want to preserve all registers as-is by the time the subroutine has finished using push and pop.
rta (Relative To Absolute), while it does not affect program flow, is still important because it allows for true position independent code in conjunction with rjmp, rloop, and rcall. it takes a relative offset specified in the source operand, adds it into rip, and loads the final result back into the target operand. take this piece of code for example:
org 0x800
rta r0, some_data ; this becomes: rta r0, 0x0C
nop
nop
nop
nop
some_data: data.8 0
the fox32 architecture does not utilise separate conditional instructions unlike other architectures. it instead encodes the condition directly into the instruction, meaning instructions can be run predicatively, instead of jumping to another block of code to run the instruction. there are 6 condition codes (7 if you include always):
this means that entire blocks of code can be written predicatively:
cmp r1, 20
iflteq ret
you should be careful when using arithmetic/bitwise operations, as those do modify the flags, meaning the original condition may be clobbered:
mov r1, 21
cmp r1, 20 ; r0 != 20, so zero flag is CLEARED
; the next instruction will execute because zero flag is 0
ifnz sub r1, 21 ; this will SET the zero flag, because 21 - 21 = 0
ifnz ret ; thus, this ret will not execute, because the zero flag was clobbered by the instruction above it
the cmp (CoMPare) instruction compares the target operand with the source operand and sets the carry flag and zero flag accordingly. internally, it determines how to set the carry and zero flag like this:
carry = (source_operand > target_operand);
zero = (target_operand == source_operand);
it can be thought of doing a subtraction operation, only that the result is not written back, it only affects the carry and zero flags.
icmp (Integer CoMPare) is just a signed version of the cmp instruction, which affects how the flags are set.
for cmp (unsigned):
for icmp (signed):
bts (Bit TeSt) will test a specific bit (specified by the source operand) in the target operand. what this actually means is that it just loads an inverted copy of that bit into the zero flag:
interrupts are mostly used for hardware events that are asynchronous (like VSYNC). they are either triggered by the hardware, or triggered by the int instruction. exceptions, on the other hand, are only triggered by the processor to indicate errors like division by zero, invalid opcodes, etc... they can also be triggered by the debugger, called with brk. additionally, the processor can use the halt instruction to halt further execution until an interrupt is triggered.
the interrupt vectors are located at the start of memory, from 0x000 to 0x3FC, where each vector is a 32-bit little-endian address to the service handler. this equates to 256 usable interrupt vectors. in addition, there are 6 exception vectors:
| vector | address | exception type |
|---|---|---|
| 0x00 | 0x400 | division by zero |
| 0x01 | 0x404 | invalid opcode |
| 0x02 | 0x408 | page fault (read) |
| 0x03 | 0x40C | page fault (write) |
| 0x04 | 0x410 | debugger breakpoint |
| 0x05 | 0x414 | bus error |
most interrupts are unused, so the architecture allocates the first 240 vectors (0x00..0xEF, vectors 0x000..0x3BC) for software use. the remaining 16 vectors (0xF0..0xFF, vectors 0x3C0..0x3FC) are allocated for hardware interrupts, although only 7 are actually used:
| vector | address | interrupt type |
|---|---|---|
| 0xF8 | 0x3E0 | timer A interrupt |
| 0xF9 | 0x3E4 | timer B interrupt |
| 0xFA | 0x3E8 | timer C interrupt |
| 0xFB | 0x3EC | timer D interrupt |
| 0xFC | 0x3F0 | (unused) |
| 0xFD | 0x3F4 | audio buffer 0 refill |
| 0xFE | 0x3F8 | audio buffer 1 refill |
| 0xFF | 0x3F4 | VSYNC interrupt (60hz) |
when dealing with interrupts/exceptions, there are 2 flags that affect how the processor handles with them:
the interrupt flag can be set with ise (Interrupt SEt) and cleared with icl (Interrupt CLear). the swap sp flag does not have any specialised instructions for setting and clearing, therefore it must be set via an interrupt:
mov [0x40], set_swap_sp ; vector 0x10
int 0x10
set_swap_sp:
add rsp, 4
bse [rsp], 3 ; enable swap sp
reti
when writing an interrupt handler, it is important to remember what the processor does when it encounters an interrupt or an exception:
if an exception was raised, then an exception operand is pushed onto the stack. the value of the exception operand is 0 by default, but it will depend on what kind of exception was raised:
| exception type | exception operand |
|---|---|
| division by zero | 0 |
| invalid opcode | 0 |
| page fault (r/w) | the memory address where the read/write failed |
| debugger breakpoint | 0 |
| bus error | 0 |
with the information above, the stack will look like this at the beginning of interrupt handling:
+---------+-----------+-----------------------------------+
| rsp+0 | 4 bytes | vector number / exception operand |
+---------+-----------+-----------------------------------+
| rsp+4 | 1 byte | processor flags |
+---------+-----------+-----------------------------------+
| rsp+5 | 4 bytes | saved return instruction pointer |
+---------+-----------+-----------------------------------+
| rsp+9 | 4 bytes | saved user stack pointer |
+---------+-----------+-----------------------------------+
when returning from an interrupt, the processor will:
notice anything about the return process? it does not pop the vector number off the stack! therefore, reti will pop the old state at the wrong place in the stack, causing stack corruption that will inevitably crash the machine. therefore, all interrupt handlers MUST correct the stack before returning:
interrupt_handler
; do interrupt handling here
add rsp, 4 ; correct the stack
reti
the position of add rsp, 4 does not really matter here, as long as it corrects the stack pointer before it returns.
when disabling interrupts with icl, the processor still processes interrupts, but does not actually jump to the handler until interrupts are enabled. during this time, interrupts are deferred and queued up in a pending state, waiting to be handled when interrupts are enabled again with ise. when interrupts are re-enabled, all deferred interrupts will be processed one after another, with the interrupt with the lowest vector number being processed first. duplicate interrupts are only processed once, so if a vector is called multiple times, it only has to be handled once.
the processor has an optional memory management unit (MMU) that can be enabled if the operating system wishes to use it. when the MMU is enabled, all memory addresses accessed by the process are treated as virtual addresses. the processor has 2 internal registers for the MMU:
the 32-bit virtual address is formatted as:
bit 31........ .......... ...........0
| |
DDDDDDDDDD TTTTTTTTTT AAAAAAAAAAAA
|||||||||| |||||||||| ^^^^^^^^^^^^---- lowest 12 bits of physical address
|||||||||| ``````````----------------- page table index (10 bits)
``````````---------------------------- page directory index (10 bits)
to translate a virtual address into a physical address, the MMU uses 2-level page tables: the page directory, and the page table. the page directory is the first level and is a 4-kilobyte block in RAM that contains 1024 32-bit entries that point to a page table. the page table is the second level and is a 4-kilobyte block in RAM that contains 1024 32-bit entries that point to an physical address. both the page directory and page table use the same bitfield:
bit 31.................. ...........0
| |
AAAAAAAAAAAAAAAAAAAA ----------WP
|||||||||||||||||||| |^----- present bit (is the page or the page table is present?)
|||||||||||||||||||| `------ writable bit (is the page writable?)
````````````````````------------------ address of page table or page (12 bits)
the process of translating a virtual address into a physical one is a multi-step process, called a page table walk:
(the information below may not necessarily be useful to programmers, but may be useful for emulator developers)
accessing memory to perform a page table walk requires at least 2 memory accesses, which can add up over time. to forestall this issue, the processor has an internal Translation Lookaside Buffer (TLB) that contains 64 entries that are filled with the final results of the virtual to physical address translation to prevent the MMU from having to retranslate on every memory access. the upper 20 bits (the page directory and table index) are used to look for a matching entry. if there is a matching entry and it is present, it returns that pre-translated physical address. otherwise, it walks the page table to get a new physical address to store in the TLB and use for future memory access. if the TLB is full, the MMU will look for an unused entry, and if none exists, it evicts entries in a simple round-robin order.
the TLB can go stale if a table entry is changed, because the TLB isn't aware of the change. thus, flp (FLush Page) can be used to flush a single page entry in the TLB, making the next access to that page walk the page table again and insert a new translation into the TLB. it is quite useful when only one page's mapping has been modified (unmapped, remapped, etc...) since it avoids throwing away the rest of the TLB's entries unnecessarily. likewise, tlb flushes the entire TLB at once, and sets the page directory pointer register to a new value, which is mandatory when switching to a different page directory, since none of the TLB entries at that moment are valid for the new address space.
in addition to the memory bus, the processor can access another bus, called the i/o bus. hardware peripherals are mapped onto this bus, which are exposed as i/o ports that can be read from or written to. unlike memory, every access is 32 bits, and there is no byte-addressing on the i/o bus. that means unlike memory-mapped i/o, ports don't have to be separated into 4 bytes for a 32-bit value, and can just be considered as one singular value. there are many things mapped onto the i/o bus that will be covered more in detail in the next sections:
| i/o port | description |
|---|---|
| 0x00000000 | serial port |
| 0x80000000-0x8000031F | graphics system |
| 0x80000400-0x80000401 | mouse input |
| 0x80000500 | keyboard input |
| 0x80000600-0x800006FF | audio system |
| 0x80000700-0x8000070F | real-time clock & programmable interval timers |
| 0x80001000-0x80005003 | disk controller |
| 0x80010000 | power controller (write 0 to power off) |
out writes a 32-bit value onto the i/o bus at the port specified in the target operand with the value in the source operand. in reads a 32-bit value from the i/o bus at the port specified in the source operand into the target operand.
all port values mentioned here are truncated versions: port 0x100 refers to port 0x80000100, and port 0x1000 refers to port 0x80001000!
the system uses a 640x480 screen and provides an RGBA8888 (that is to say: 8 bits for Red, Green, Blue, and Alpha) framebuffer fixed in memory at address 0x02000000-0x0212BFFF (1228800 bytes -> 307200 pixels). the memory here is directly readable and writable like normal RAM, but the location of the framebuffer cannot be relocated in any way. it should be noted that the alpha channel, although 8 bits have been allocated for it, is essentially a binary value, where a zero alpha value causes the pixel to be completely transparent, and a non-zero value to be completely opaque. this framebuffer is called the "background", because alongside it are 32 "overlays", which can be very flexible unlike the background. they are essentially hardware sprites, and can be used to generate windows, cursors, etc...
the overlays are composited onto the background, with overlay 31 being given the highest priority, and overlay 0 the lowest priority. unlike the background, they can have their own X and Y position, set with port 0x0NN (NN=00..1F). the upper 16 bits of the port determine the Y position, and the lower 16 bits determining the X position. they can also have their own size, set with port 0x1NN, where the upper 16 bits determine the height, and the lower 16 bits the width. the overlays also have their own framebuffer in memory (whose size is variable depending on the height and width), set with port 0x2NN. port 0x3NN determines if the overlay is composited onto the screen, with a zero value meaning no display, and a non-zero value meaning it is displayed.
overlay port mappings (NN=00..1F)
| port | description |
|---|---|
| 0x0NN | overlay position (bits 31:16 - y pos, 15:0 - x pos) |
| 0x1NN | overlay size (bits 31:16 - height, 15:0 - width) |
| 0x2NN | overlay framebuffer address |
| 0x3NN | overlay enable (0 - disabled, non-zero - enabled) |
there are 2 input methods available: the mouse, and the keyboard. because they occupy so little i/o space, and have basically no quirks, they are merged into this one section. the mouse is accessed with ports 0x400 (mouse status) and 0x401 (mouse position). the computer does not make a distinction between the left and right mouse buttons.
port 0x400 (mouse status)
| bit | description |
|---|---|
| 2 | button held |
| 1 | button release |
| 0 | button press |
port 0x401 (mouse position)
| bit | description |
|---|---|
| 31:16 | Y position |
| 15:0 | X position |
note that the positions above are absolute rather than delta-relative, meaning the Y position ranges from 0 to 479, and X from 0 to 639.
the keyboard only has one port at 0x500. when read, the keyboard returns a key event from the key event queue. bit 7 of this value determines if it was pressed (0) or released (1). the remaining 7 bits are used for the scancode. if you are using the keyboard, do not use the F11 key for any function! that key in the emulator is mapped to debug mode, where it prints out every instruction being executed to console. this will freeze your emulator!
scancode mapping
| key | scancode |
|---|---|
| Escape | 0x01 / 1 |
| 1 | 0x02 / 2 |
| 2 | 0x03 / 3 |
| 3 | 0x04 / 4 |
| 4 | 0x05 / 5 |
| 5 | 0x06 / 6 |
| 6 | 0x07 / 7 |
| 7 | 0x08 / 8 |
| 8 | 0x09 / 9 |
| 9 | 0x0A / 10 |
| 0 | 0x0B / 11 |
| Minus (-) | 0x0C / 12 |
| Equals (=) | 0x0D / 13 |
| Backspace | 0x0E / 14 |
| Tab | 0x0F / 15 |
| Q | 0x10 / 16 |
| W | 0x11 / 17 |
| E | 0x12 / 18 |
| R | 0x13 / 19 |
| T | 0x14 / 20 |
| Y | 0x15 / 21 |
| U | 0x16 / 22 |
| I | 0x17 / 23 |
| O | 0x18 / 24 |
| P | 0x19 / 25 |
| Left Bracket ([) | 0x1A / 26 |
| Right Bracket (]) | 0x1B / 27 |
| Enter | 0x1C / 28 |
| Left Control | 0x1D / 29 |
| A | 0x1E / 30 |
| S | 0x1F / 31 |
| D | 0x20 / 32 |
| F | 0x21 / 33 |
| G | 0x22 / 34 |
| H | 0x23 / 35 |
| J | 0x24 / 36 |
| K | 0x25 / 37 |
| L | 0x26 / 38 |
| Semicolon (;) | 0x27 / 39 |
| Single Quote (') | 0x28 / 40 |
| Backtick (`) | 0x29 / 41 |
| Left Shift | 0x2A / 42 |
| Backslash (\) | 0x2B / 43 |
| Z | 0x2C / 44 |
| X | 0x2D / 45 |
| C | 0x2E / 46 |
| V | 0x2F / 47 |
| B | 0x30 / 48 |
| N | 0x31 / 49 |
| M | 0x32 / 50 |
| Comma (,) | 0x33 / 51 |
| Period (.) | 0x34 / 52 |
| Forward Slash (/) | 0x35 / 53 |
| Right Shift | 0x36 / 54 |
| Keypad * | 0x37 / 55 |
| Left Alt | 0x38 / 56 |
| Spacebar | 0x39 / 57 |
| Caps Lock | 0x3A / 58 |
| F1 | 0x3B / 59 |
| F2 | 0x3C / 60 |
| F3 | 0x3D / 61 |
| F4 | 0x3E / 62 |
| F5 | 0x3F / 63 |
| F6 | 0x40 / 64 |
| F7 | 0x41 / 65 |
| F8 | 0x42 / 66 |
| F9 | 0x43 / 67 |
| F10 | 0x44 / 68 |
| Num Lock | 0x45 / 69 |
| Scroll Lock | 0x46 / 70 |
| Keypad 7 | 0x47 / 71 |
| Keypad 8 | 0x48 / 72 |
| Keypad 9 | 0x49 / 73 |
| Keypad Minus (-) | 0x4A / 74 |
| Keypad 4 | 0x4B / 75 |
| Keypad 5 | 0x4C / 76 |
| Keypad 6 | 0x4D / 77 |
| Keypad Plus (+) | 0x4E / 78 |
| Keypad 1 | 0x4F / 79 |
| Keypad 2 | 0x50 / 80 |
| Keypad 3 | 0x51 / 81 |
| Keypad 0 | 0x52 / 82 |
| Keypad Period (.) | 0x53 / 83 |
| F11 | 0x57 / 87 |
| F12 | 0x58 / 88 |
| Up Arrow | 0x67 / 103 |
| Left Arrow | 0x69 / 105 |
| Right Arrow | 0x6A / 106 |
| Down Arrow | 0x6C / 108 |
the audio controller manages 8 PCM audio channels, summing them together to produce the final audio output. it has a maximum output rate of 48 kHz and has 2 modes: DMA (Direct Memory Access) mode, and buffer mode. in DMA mode, you can use 8 channels of PCM, and the audio controller will automatically fetch the samples when needed, freeing up the processor to do other tasks. in buffer mode, those channels are unavailable, and audio data must instead be provided by the processor at regular intervals. in both modes, the audio controller can fetch and output signed 8-bit or signed 16-bit PCM samples.
registers for the 8 audio channels are accessed at 0x600-0x67F. the upper nibble determines the channel number, while the lower nibble determines the register. general audio controller registers start at 0x680 and continue to 0x684, with ports 0x685..0x6FF reserved for future use.
PCM channel port mappings (N=0..7)
| port | description |
|---|---|
| 0x6N0 | audio channel X sample start (write), current sample position (read) |
| 0x6N1 | audio channel X sample end (write), current output sample (read) |
| 0x6N2 | audio channel X loop point start (write-only) |
| 0x6N3 | audio channel X loop point end (write-only) |
| 0x6N4 | audio channel X sample rate (write-only), current channel phase (read) |
| 0x6N5 | audio channel X control |
| 0x6N6 | audio channel X panning |
general audio controller port mappings
| port | description |
|---|---|
| 0x680 | audio controller sample base |
| 0x681 | audio controller control |
| 0x682 | audio controller buffer 0 start location |
| 0x683 | audio controller buffer 1 start location |
| 0x684 | audio controller buffer length |
port 0x681 (audio controller control)
bit 15...... .......0
| |
RRRRRRRR --FF-10M
|||||||| || ||`--- mode (0=DMA mode, 1=buffer mode)
|||||||| || |`---- buffer 0 refill pending
|||||||| || `----- buffer 1 refill pending
|||||||| ``------- buffer format (00 - mono 8-bit, 01 - mono 16-bit, 10 - stereo 8-bit, 11 - stereo 16-bit)
````````------------ buffer rate (0..128)
sample start points and end points must be specified in bytes relative to the sample base (set in port 0x680). this means if a sample is 8000 samples long and is located at the very beginning of sample base (and uses 8-bit PCM), the sample start point should be set to 0, and the sample end point to 8000. if it's 16-bit PCM, the end point is at byte 16000. this also applies to the start points and end points for looping.
samples are fetched at the sample rate specified in port 0x6N4, which is a value ranging from 0-65536, giving an effective frequency range of 0hz-48khz. samples in this mode can also be only mono, and 16-bit samples are fetched in little-endian halves. the sample rate is calculated with the formula:
frequency * 2^16
sample rate = ----------------
48000
the sample playback behaviour is determined in the control port (0x6N5). 9 bits are used to determine playback:
bit 9. .......0
| |
PE LVVVVVVV
|| |```````--- volume (0..127, where 0 is silent and 127 is loudest)
|| `---------- loop (0=one-shot, 1=loop after reaching the end)
|`------------ enable (0=silence the channel, 1=reset the sample position)
`------------- pcm data (0=8-bit PCM, 1=16-bit PCM)
bit 8 (the enable bit) is a strobe register, meaning any writes that write a 1 to this bit will reset the current sample position, and any writes that write a 0 will silence the channel! therefore, it is not recommended to use this register to control the volume, and instead use the panning registers to control the volume.
the panning registers are a 16-bit port, where the upper 8 bits are the left channel volume, and the lower 8 bits are for the right channel.
bit 15...... .......0
| |
LLLLLLLL RRRRRRRR
|||||||| ````````--- right channel volume (0..255, where 0 is silent and 255 is loudest)
````````------------ left channel volume
the samples are modulated using the volumes in the order: control volume -> left/right volume. therefore, it is usually better to leave the channel at maximum control volume, then modulate the volume with the panning register.
it is important to note that when reading the current sample output via port 0x6N1, the sample that is returned is actually the raw sample data that hasn't been modulated by the volume registers. therefore, if you are planning to plot it on an oscilloscope, you must scale the output manually in software. another peculiarity is that when reading 8-bit PCM samples, the maximum output that can be returned is 0x7f00. internally, the audio controller always outputs 16-bit PCM, but to facilitate 8-bit PCM output, all 8-bit samples are shifted up by 8 bits to get a 16-bit output.
(the information below may not necessarily be useful to programmers, but may be useful for emulator developers)
internally, the audio controller uses a 16.16 fixedpoint phase accumulator to determine when to fetch samples in DMA mode. the behaviour of this accumulator can be best described with the following pseudocode:
Phase := 0
WHILE ChannelEnabled DO
Phase += SampleRate
IF Phase >= 65536 THEN
Phase -= 65536
Sample := FetchNewSample()
OutputToChannel(Sample)
END
END
buffer mode is enabled when bit 0 of the control register (port 0x681) is set to 1. in this mode, samples are no longer automatically fetched according to a set start and endpoint (this also makes the 8 PCM channels unusable). instead, the audio controller uses 2 buffers (buffer 0 and buffer 1) to output samples. when the controller starts reading samples, it will read buffer 0 first. when it has reached the end of buffer 0, it will interrupt to the CPU to refill buffer 0 (vector 0xFD), while it starts reading buffer 1. when it reaches the end of buffer 1, it will also interrupt the CPU to refill buffer 1 (vector 0xFE), and restarts reading from buffer 0. the start address for buffer 0 is determined by port 0x682, and 0x683 for buffer 1. the length of these buffers (in bytes) is determined by port 0x684.
stereo samples are fetched as frames containing left and right samples. for instance, if the buffer is set to stereo 8-bit PCM, it fetches the left sample first (1 byte), then the right sample last (1 byte), advancing the internal position by 2 bytes. for stereo 16-bit PCM, it fetches the left sample word first (2 bytes) and the right sample word last (2 bytes), advancing the internal position by 4 bytes.
like DMA mode, samples are fetched from the buffer at the rate specified in bits 15:8 in the control register. it works exactly like the DMA mode's phase accumulator, but the range has been reduced to 8 bits. the maximum rate in buffer mode is 128, meaning a sample rate of 48khz. the buffer rate can be calculated with the formula:
frequency * 2^8
buffer rate = -----------------
48000
during a refill interrupt, the interrupt should be acknowledged in the service handler to allow future refill interrupts to be triggered. therefore, a 0 should be written to the corresponding pending flag in the control register:
; acknowledge interrupt for buffer 0
in r0, 0x80000681
bcl r0, 1
out 0x80000681, r0
; acknowledge interrupt for buffer 1
in r0, 0x80000681
bcl r0, 2
out 0x80000681, r0
the fox32 computer contains a real-time clock, and 4 programmable interval timers that can be set to cause an interrupt at specified regular intervals. the real-time clock is located at ports 0x700-0x708. the timers are located at ports 0x709-0x70F. the real-time clock in the emulator is linked to the host's current time, meaning they can be used for calendars, clocks, etc... the interval timers are mostly for scheduling code to be executed at certain intervals, and are not temporally accurate at sub-frame timings, because they are ticked according to CPU instruction ticks (which are emulated in short bursts), rather than real time. therefore, they are not recommended to use them for precise timekeeping applications.
real-time clock port mappings
| port | description |
|---|---|
| 0x700 | year (e.g 2026) |
| 0x701 | month (1-12) |
| 0x702 | day (1-31) |
| 0x703 | hour (0-23) |
| 0x704 | minute (0-59) |
| 0x705 | second (0-59) |
| 0x706 | miliseconds since startup |
| 0x707 | daylight savings time active (zero or non-zero) |
| 0x708 | instructions since startup |
interval timer port mappings
| port | description |
|---|---|
| 0x709 | tick counter |
| 0x70A | tick base |
| 0x70B | timer control |
| 0x70C | timer A period (write), counter (read) |
| 0x70D | timer B period (write), counter (read) |
| 0x70E | timer C period (write), counter (read) |
| 0x70F | timer D period (write), counter (read) |
all bits in the timer control register are grouped together in the order: timer D -> timer C -> timer B -> timer A.
port 0x70B (timer control)
| bits | description |
|---|---|
| 23:20 | 0=disable interrupt, 1=enable interrupt |
| 19:16 | (write-only) 1=force reload the corresponding timer |
| 15:12 | 0=one-shot, 1=continuous |
| 11:8 | 0=clock per tick, 1=clock per cycle |
| 7:4 | 0=no interrupt pending, 1=interrupt pending |
| 3:0 | 0=disable timer, 1=enable timer |
the tick counter increments by 1 every N instructions, where N is the value in the tick base register (port 0x70A). it can be used as a timing source for interval timers, alongside instruction cycles. if the clock is set to clock per tick, then every time the tick counter increases by 1, the timers that are clocked according to it decrease by 1, meaning that the tick counter acts like a period divisor, as opposed to timers clocked per cycle, which decrement by 1 every instruction.
timers count down from the period, set in ports 0x0C-0x0F, to 0. an interrupt is triggered during the transition from 1 to 0. writing to the timer's period port will not reset the counter, and the new period will only take effect after its current period has finished. the counter can be reset with the new period by writing a 1 to bits 16-19 to force reload it.
when the timer is set to one-shot mode, after the timer expires, the period will not be automatically reloaded into the counter. on the other hand, continuous mode will reload the period into the counter after the timer expires.
during a timer interrupt, the handler must acknowledge the interrupt by writing a 0 to bits 4-7. this can be done with the following:
in r0, 0x8000070B
bcl r0, 4 ; acknowledge timer A interrupt
out 0x8000070B, r0
the disk controller is located at ports 0x1000 to 0x5003. there are only 4 disk ports, specified by the lower nibble of the port (0x1002 means disk port 2). each disk is organised into 512-byte sectors. the 0x100N (N=0..3) ports are read-only and return the disk size (in bytes!). port 0x200N determines where the data buffer for disk read and write operations is in RAM. port 0x300N is a write-only strobe register that initiates a disk read, and writes the content of the disk into the data buffer specified by port 0x200N. port 0x400N is also a write-only strobe register like 0x300N, but for disk writes, and reads the content of the data buffer and writes it onto the disk. in both cases, the value that should be written into them should be a disk sector number. the disk controller is synchronous, meaning the processor will be halted during a disk read or write! to eject or remove a disk, write any value into port 0x500N.
; this code will read sector 18 from disk 3 into 0x4000 in memory
out 0x8000202, 0x4000 ; data buffer at 0x4000
out 0x8000302, 18 ; read sector 18
; this code will write the contents off 0x5000..0x51FF (512 bytes) into sector 19 disk 3
out 0x8000202, 0x5000 ; data buffer at 0x5000
out 0x8000302, 19 ; write into sector 19
disk controller port mappings
| port | description |
|---|---|
| 0x1000-0x1003 | disk size (read-only) |
| 0x2000-0x2003 | buffer pointer |
| 0x3000-0x3003 | initiate disk read (write-strobe) |
| 0x4000-0x4003 | initiate disk write (write-strobe) |
| 0x5000-0x5003 | remove disk (write-strobe) |
the serial port is located at the very beginning of i/o space, and has only 1 port at 0x00000000. nominally, this is wired to a debug terminal (or stdout in the emulator) to be used for debug purposes. writing a value to this register will cause the lowest 8 bits of the value written to it to be output on the serial port. this is a synchronous operation, meaning the processor will stall for some amount of time to ensure the value is output, rather than discarded. reading from this register returns a byte of input from the serial port, which is wired to stdin in the emulator.