-
Notifications
You must be signed in to change notification settings - Fork 14
QNICE and the Shell ROM (m2m rom.asm)
The MEGA65 has no ARM processor and runs no Linux.
On a MiSTer, the FPGA holds only the emulated machine; everything around it — the
on-screen menu, the file browser, mounting a disk image, loading a cartridge, saving your
settings — is handled by a full ARM processor running Linux, sitting on the same board. The
MEGA65 has no such helper. So MiSTer2MEGA65 (M2M) does something quietly audacious: it
builds a small but complete computer inside the FPGA, in pure logic, and gives it the job
that ARM+Linux does on MiSTer. That in-fabric computer is QNICE, and the program it runs
is the Shell. The one file of that program every core author touches is m2m-rom.asm.
This is the cover-to-cover guide to that world. We start with QNICE the processor (Part 1)
and its assembly language (Part 2), because you cannot read the Shell without them. Then we
cover the tool that makes firmware development bearable — the Monitor, and the trick of
reloading your firmware over JTAG without ever re-synthesizing (Part 3). With that in hand we
open m2m-rom.asm itself (Part 4), study the callbacks where the Shell stops and asks
your core what to do (Part 5), catalog the library functions you may call (Part 6), and
finish with the advanced art of driving Shell internals to achieve things the framework never
shipped — real-time disk write-back is our case study (Part 7). A short closing part (Part 8)
covers the memory budget you tune and the fatal-error messages you will eventually meet.
This is a concepts and reference article: it teaches you why the pieces are shaped the way they are, and gives you a durable reference to return to. The exact click-by-click porting steps live in The Ultimate MiSTer2MEGA65 Porting Guide; this article is what makes those steps make sense.
If you take one sentence away: QNICE is a tiny 16-bit CPU that plays the role MiSTer hands to
ARM+Linux; m2m-rom.asm is the program it runs, and nearly everything you will ever change
lives in a handful of assembly "callback" functions where the Shell pauses and asks your core
what to do.
It helps to picture M2M as a theatre. The core — the emulated C64, Amiga, or arcade board — is the play on stage. But a play needs a stage manager backstage: someone to raise the curtain, hand the actors their props, swap the scenery, dim the lights, and keep the prompt-book. On MiSTer that stage manager is an ARM chip running Linux. On the MEGA65 there is no ARM and no Linux, so M2M hires a stage manager built entirely out of FPGA logic. That is QNICE.
Concretely, QNICE is the CPU that:
- draws the On-Screen-Menu (the overlay you get by pressing Help) and the welcome and help screens;
- runs the file and directory browser and reads the SD card's FAT32 filesystem;
- mounts disk images and streams them into memory as virtual floppies;
- loads ROMs and cartridges into the core;
- persists your settings back to the SD card;
- and, in between, services the core's requests — a disk block here, a saved sector there.
None of this is the emulated machine. It is the machinery around the machine, and it all runs as a program on QNICE. Understanding QNICE is therefore the price of admission to customizing any of it.
QNICE (pronounced "Q-nice") is a 16-bit processor designed by Bernd Ulmann, and QNICE-FPGA is sy2002's implementation of it as a complete System-on-a-Chip: not just a CPU, but a CPU wrapped with memory, a UART (serial port), an SD-card controller, a maths accelerator, and — importantly — a small operating system called the Monitor, an assembler, a C compiler, and a cycle-accurate emulator. It is a real, self-contained retro platform in its own right; M2M simply embeds it and puts it to work. If you want the full background, the project lives at https://qnice-fpga.com and https://github.com/sy2002/QNICE-FPGA.
Two design choices matter to you as a reader of its assembly. First, QNICE is deliberately tiny: the entire instruction set is 18 instructions with just 4 ways of addressing memory. You can hold the whole architecture in your head, which is exactly the point — it was built to be teachable. Second, it is a word machine: memory is addressed in 16-bit words, not bytes. There is no such thing as "the byte at address 5"; there is "the 16-bit word at address 5." Every address, every register, every instruction is one word wide. Hold onto that; it explains a lot of what follows.
QNICE has a 16-bit address bus, so it can name 65,536 words — a 64 KW (kilo-word) space. That space is carved into three regions:
| Range | Region | What lives here |
|---|---|---|
0x0000 – 0x6FFF
|
ROM | the firmware program — the Shell (m2m-rom.rom) |
0x8000 – 0xFEFF
|
RAM | variables, the heap, and the stack |
0xFF00 – 0xFFFF
|
I/O page | the top 256 words: memory-mapped device registers |
(The range 0x7000 – 0x7FFF looks like ROM but is special; M2M repurposes it as a sliding
"window" onto the wider FPGA — see §1.4. So the firmware image itself occupies 0x0000 up to
just below 0x7000.)
The single most important idea here is the one hiding in that last row: QNICE has no I/O
instructions. There is no IN or OUT. A device is just a handful of memory cells up in the
I/O page, and you talk to it with the same MOVE you would use for any other memory. Want to
send a character to the serial port? You write it to a memory address. Want to read which keys
are pressed? You read a memory address. This "memory-mapped I/O" model is why, once you know
how to move data around in QNICE, you already know how to drive every device — including all of
M2M's own hardware, whose registers are documented in M2M/rom/sysdef.asm (the software's view
of the hardware, mirrored bit-for-bit in the VHDL).
The stack grows downward from the top of RAM (just below the I/O page, around 0xFEE0); the
heap grows upward from the end of your variables. They march toward each other through the
same RAM, which is why the Shell has a carefully planned memory budget — the subject of Part 8.
Here is a puzzle. QNICE has only 64 KW of address space, but the FPGA world it must manage is enormous: 8 MB of HyperRAM, video RAM, the 256-bit menu-state register, a config ROM holding all your menu text, the scaler's coefficient tables, and every device your core exposes. That will never fit in 64 KW. How does a tiny CPU reach a huge world?
Through a porthole. M2M reserves one 4 KW region of QNICE's address space — 0x7000 to
0x7FFF — as a movable window, and adds two small control registers that decide what the
window looks at:
-
M2M$RAMROM_DEV(at0xFFF4) selects the device — which piece of the FPGA world. -
M2M$RAMROM_4KWIN(at0xFFF5) selects the 4 KW window — which slice of that device.
Write a device number and a window number into those two registers, and the 4 KW at 0x7000
now is that slice of that device. The hardware forms a full 28-bit address behind the scenes
as window × 4096 + offset. To read the millionth word of HyperRAM, you point the porthole at
the HyperRAM device, dial the window that contains word one million, and read from 0x7000
plus the leftover offset. It is exactly like sliding a small porthole along the hull of a very
large ship: the porthole is modest, but there is no part of the hull you cannot bring into
view.
This window model is the contract between QNICE and the rest of M2M, so it is worth memorizing the one rule that governs it:
Device IDs below
0x0100are reserved by the framework. Device IDs0x0100and above belong to your core. When you add your own RAM, ROM, or register block to a core, it answers at a device ID of0x0100or higher; anything lower is M2M's (video RAM, the config ROM, the scaler, HyperRAM at0x0004, and so on).
The full list of framework device IDs and the register layout of each lives in sysdef.asm,
and the Devices article walks through them. For now, just hold the picture: a small CPU,
a movable porthole, and a rule about who owns which device numbers.
QNICE-FPGA as a standalone computer has a text-mode VGA controller, a PS/2 keyboard port, timer interrupts, a 7-segment display, and 256 banks of registers. The QNICE inside M2M has almost none of that. This is not a bug; it is a deliberate diet, for two reasons: FPGA logic is finite, and M2M already provides those services its own way (its own on-screen overlay, its own keyboard path, its own video pipeline). So M2M keeps the parts of QNICE it needs as a controller and drops the parts it would only duplicate.
What M2M keeps: the CPU and its ALU, ROM and RAM, the UART (the serial link you will use to talk to it — Part 3), the EAE (an "Extended Arithmetic Element" that does 32-bit multiply and divide, because the CPU itself cannot multiply), the SD-card controller (with a two-slot multiplexer M2M adds on top), and a cycle counter.
What M2M drops: the text-mode VGA controller, the PS/2 keyboard, the instruction counter, the two hardware timers, the 7-segment display, the power-on boot ROM (PORE), and — notably — interrupts are wired off entirely. QNICE can do interrupts; M2M chooses not to, which keeps the firmware model simple: the Shell is a single loop that is never preempted.
What M2M adds: all the M2M-specific registers up in the I/O page — the control-and-status
register M2M$CSR (0xFFE0, which resets and pauses the core, shows the menu, connects the
keyboard and joysticks, and selects the SD slot), the OSM position registers, the scaler-mode
register, the 256-bit menu-state banks (M2M$CFM_DATA and friends), and the two porthole
selectors from §1.4.
One number is worth remembering because it can bite you: M2M shrinks the register file from
256 banks down to 32 (the constant SHADOW_REGFILE_SIZE in M2M/vhdl/QNICE/qnice_globals.vhd)
to save FPGA area. What a "register bank" is, and why 32 is plenty, is the first topic of
Part 2 — where we finally start reading and writing QNICE code.
You do not need to write much QNICE to port a core — most of your work is filling in a few callback functions (Part 5). But you must be able to read it fluently, because the Shell is QNICE assembly and the callbacks live inside it. The good news, promised in Part 1, is that there is very little to learn. This part teaches all of it.
At any instant, QNICE shows the program 16 registers, R0 through R15. Three of them have
fixed jobs, and by strong convention the assembler even gives them names:
-
R15is the program counter (PC) — the address of the instruction being executed. -
R14is the status register (SR) — the flags (§2.4), plus the register-bank selector. -
R13is the stack pointer (SP) — the top of the stack.
That leaves R0–R12 as general-purpose registers. But they are not all alike, and this is
the one genuinely unusual thing about QNICE:
R8–R12are global.R0–R7are a window onto a much larger bank of registers.
Think of R0–R7 as a workbench with only eight tool slots, sitting on a lazy-Susan
turntable. The turntable has many identical eight-slot faces stacked behind the one you see. At
any moment you work with the eight tools in front of you; rotate the turntable one click and
you get a fresh, empty set of eight — your previous eight are still there, untouched, just
rotated out of view. The two instructions that rotate the turntable are:
-
INCRB— increment the register bank: give me the next fresh set ofR0–R7. -
DECRB— decrement the register bank: rotate back to the previous set.
(The current bank number lives in the top 8 bits of SR, which is why SR is more than just
flags.) On MiSTer's ARM you would push registers onto the stack when entering a subroutine and
pop them on the way out. On QNICE you usually just INCRB on entry and DECRB on exit: the
subroutine gets eight private scratch registers for free, without touching the stack at all.
This is why nearly every routine you will read in the Shell looks like:
MY_ROUTINE INCRB ; borrow a fresh R0..R7
... ; do the work in R0..R7
DECRB ; hand the caller's R0..R7 back
RET ; return
M2M gives you 32 of these banks (Part 1, §1.5). That is deep enough for the Shell's nested
calls, but not infinite — it is one of the few resources you could theoretically exhaust, so
the rule is one INCRB/DECRB pair per routine, balanced. R8–R12 are global on purpose:
they are how a caller passes arguments into a subroutine and gets results back out, since
rotating the R0–R7 turntable leaves them alone. More on that in §2.5.
The workhorse instruction is MOVE src, dst — it copies src into dst. Every two-operand
instruction, MOVE included, can reach its operands in exactly four ways, called
addressing modes. Learn these four and you have learned how QNICE touches memory:
| Mode | Written | Meaning |
|---|---|---|
| register direct | Rxx |
the operand is the register |
| register indirect | @Rxx |
the operand is the memory word that Rxx points to |
| post-increment | @Rxx++ |
use @Rxx, then add 1 to Rxx
|
| pre-decrement | @--Rxx |
subtract 1 from Rxx first, then use @Rxx
|
So MOVE R1, R2 copies register to register; MOVE @R1, R2 reads the word R1 points at;
MOVE @R1++, R2 reads it and advances R1 to the next word (perfect for walking through an
array); MOVE R2, @--R1 steps R1 back and stores there.
Those last two modes are exactly what a stack needs — and QNICE has no special push/pop
instructions because it does not need them. With SP (R13) as the pointer:
MOVE R8, @--SP ; PUSH R8 (pre-decrement: grow the stack, then store)
...
MOVE @SP++, R8 ; POP R8 (post-increment: read, then shrink the stack)
The stack grows downward, so pushing pre-decrements and popping post-increments. Any
register can act as a stack pointer this way; SP is just the conventional one.
There is one more place these modes quietly appear: constants. QNICE has no separate "immediate" mode. When you write
MOVE 0x1234, R0 ; put the literal value 0x1234 into R0
the assembler stores 0x1234 in the memory word right after the instruction and secretly
assembles it as MOVE @R15++, R0 — "read the word the program counter points at (the one
following me), then step the PC past it." That is a neat little trick: because PC is just
R15, the same post-increment mode that walks an array also fetches inline constants. The only
practical consequence is that an instruction carrying a constant is two words long instead
of one, which matters when you count RBRA branch distances or ROM space.
QNICE has 18 instructions. Fourteen of them are the arithmetic/logic/data ops, and they all
follow the same shape INSTR src, dst, computing a result into dst:
| Instr | Effect |
|---|---|
MOVE src, dst |
dst := src |
ADD src, dst |
dst := dst + src |
ADDC src, dst |
dst := dst + src + carry (add with carry — chains across words) |
SUB src, dst |
dst := dst - src |
SUBC src, dst |
dst := dst - src - carry (subtract with borrow) |
AND src, dst |
dst := dst AND src (bitwise) |
OR src, dst |
dst := dst OR src |
XOR src, dst |
dst := dst XOR src |
NOT src, dst |
dst := bitwise-complement of src |
SHL src, dst |
shift dst left by src bits |
SHR src, dst |
shift dst right by src bits |
SWAP src, dst |
dst := src with its high and low bytes exchanged |
CMP src, dst |
compare src with dst, setting flags only (see §2.4) |
A few notes worth having. There is no multiply or divide in the CPU itself — QNICE is that
small; multiplication and division are done by the EAE co-processor (Part 1) through library
calls (MTH$MULU, MTH$DIVU, and friends — Part 6). Clearing a register idiomatically is
XOR R8, R8 (anything XOR'd with itself is zero) rather than MOVE 0, R8, because the former
is one word and the latter is two. And the shifts have a QNICE quirk: alongside the familiar
carry bit C, QNICE keeps a second boundary bit called X, and the shift instructions move
bits between C and X so that a shift spanning several 16-bit words can be chained cleanly.
The remaining four instructions are the branches (ABRA, ASUB, RBRA, RSUB), which
change what runs next; they get their own section (§2.4) because they are inseparable from the
status register. There is also a small control group sharing one opcode — you have already
met INCRB and DECRB; the others are HALT (stop the processor), and RTI/INT
(return-from / trigger a software interrupt, which M2M does not use). HALT is what a finished
test program runs; a running Shell never halts.
Every arithmetic and logic instruction, as a side effect, updates the flags in the low byte of
the status register SR. There are five that matter, plus one that is always on:
| Flag | Set when… |
|---|---|
Z |
the last result was zero |
N |
the last result was negative (top bit set) |
C |
the last operation produced a carry/borrow out |
V |
the last operation overflowed (two positives made a negative, or vice-versa) |
X |
the last result was 0xFFFF (all ones); also the shift companion bit (§2.3) |
1 |
always 1 — never changes |
Now the elegant part. QNICE has no JZ/JNZ/JC zoo of separate conditional jumps. Instead,
every branch instruction is conditional: you name one flag to test, and the branch is taken
only if that flag is set. Put a ! in front to invert the test. And to branch
unconditionally, you test the flag that is always 1:
RBRA LABEL, Z ; branch if the Zero flag is set
RBRA LABEL, !Z ; branch if the Zero flag is clear
RBRA LABEL, C ; branch if Carry is set
RBRA LABEL, 1 ; branch ALWAYS (test the always-1 flag)
That "test the always-1 bit" is why you see , 1 at the end of every unconditional branch and
every SYSCALL in the Shell — it literally means "the condition that is never false." Once you
see it, the whole codebase reads more easily.
Because comparisons are so common, CMP src, dst exists purely to set the flags without storing
a result — and it sets them for both signed and unsigned interpretations, so the same
compare works either way depending on which flag you branch on afterwards. A CMP immediately
followed by a conditional branch is the QNICE idiom for "if". Here is a complete program that
sums the value 16 a total of 16 times, using a countdown loop:
.ORG 0x8000
XOR R0, R0 ; R0 = 0 (the running total)
MOVE 0x0010, R1 ; R1 = 16 (loop counter)
LOOP ADD R1, R0 ; R0 = R0 + R1
SUB 1, R1 ; R1 = R1 - 1 (this sets Z when R1 hits 0)
RBRA LOOP, !Z ; repeat while the counter is not yet zero
HALT ; stop; R0 now holds the sum
The four branch instructions differ along two independent axes. One axis is absolute vs.
relative: ABRA/ASUB jump to an absolute address, while RBRA/RSUB jump to an address
computed relative to the current PC (which makes the code position-independent and the
branch encoding compact). The other axis is branch vs. call: ABRA/RBRA just change
PC, whereas ASUB/RSUB first push the return address onto the stack before jumping, so the
called routine can return. In practice you will read RSUB for "call this subroutine" and
RBRA for "go here," with the A-forms appearing where an absolute target is needed.
| branch (jump) | call (push return address) | |
|---|---|---|
| absolute target | ABRA dst, cond |
ASUB dst, cond |
| relative target | RBRA dst, cond |
RSUB dst, cond |
Putting §2.1 and §2.4 together gives you the QNICE calling convention — the etiquette every Shell routine and every callback obeys:
-
Arguments and return values travel in
R8–R12. The caller loadsR8(andR9,R10… as needed) before the call; the routine leaves its results in the same registers. Because these are the global registers, they survive the callee'sINCRB. -
The routine gets private scratch via
INCRB/DECRB. It works inR0–R7, confident it is not clobbering the caller'sR0–R7. -
A boolean result is often returned in the carry flag instead of a register, so the caller
can
RBRA ..., Cright after the call with no compare. Many Shell helpers (likeM2M$CHK_EXT, "does this filename end in this extension?") answer this way. -
RETreturns. It is not a dedicated instruction — it is the macroMOVE @R13++, R15, i.e. "pop the saved return address off the stack into the program counter." (Symmetrically,RSUBpushed it there.)
The canonical routine, which you will recognize on sight throughout the Shell:
MOVE FILENAME, R8 ; argument: pointer to a string
MOVE EXTENSION, R9 ; argument: pointer to ".ADF"
RSUB M2M$CHK_EXT, 1 ; call; result comes back in the carry flag
RBRA ITS_AN_ADF, C ; branch if the extension matched
...
M2M$CHK_EXT INCRB ; fresh scratch bank
... ; work in R0..R7, inspect R8/R9
DECRB ; restore the caller's bank
RET ; carry flag already holds the answer
To call the QNICE operating system (the Monitor, Part 3) rather than a Shell routine, you
use SYSCALL instead of RSUB. It looks and behaves like a call — the trailing , 1 is the
same "always" condition — but it routes through a fixed jump table so the Monitor's internals
can move without breaking your code:
MOVE MESSAGE, R8 ; R8 = pointer to a zero-terminated string
SYSCALL(puts, 1) ; print it (to the serial console and/or OSM)
You have now seen every mechanism the Shell uses: banked scratch registers, R8–R12 for
arguments, carry-flag booleans, RSUB/RET for Shell routines, and SYSCALL for the Monitor.
The callbacks in Part 5 are just routines that follow exactly this etiquette, called by the
Shell instead of into it.
The QNICE assembler is qasm, wrapped by a script named asm. A handful of facts about it will
save you real debugging time.
It is multi-pass, so #include order does not matter. The assembler resolves all labels in
a later pass, so you can reference a label defined further down the file, and you can #include
files in any order. Reorder includes for readability, never to satisfy the assembler.
Your source is run through the C preprocessor first — so no apostrophes in comments. The
asm wrapper pipes every file through the C preprocessor (which is what gives you #include,
#define, and #ifdef). A lone apostrophe in a comment — writing "the caller's buffer" or
"MiSTer's framework" — is read by the preprocessor as the start of a character literal that
never ends, and floods your build with warnings. Rephrase to avoid the possessive. (Paired
double-quotes inside a real .ASCII_W "..." string are fine.)
$ is a normal identifier character. This is why labels look like M2M$CSR,
FAT32$FDH_FLAGS, and SLL$NEXT: the $ is just a namespace separator by convention, dividing
a subsystem prefix from the name. (As a number prefix, a leading $ means hexadecimal, so
$FF00 equals 0xFF00, but M2M code overwhelmingly uses the 0x form.)
The directives you will actually meet:
| Directive | What it does |
|---|---|
.ORG addr |
set the assembly address (where the following code/data lands) |
.EQU value |
bind the line's label to a constant (this is how sysdef.asm names every register) |
.DW w0, w1, … |
emit literal data words |
.ASCII_W "text" |
store a string, one character per word, zero-word-terminated (the "W" is C-string-like) |
.ASCII_P "text" |
same, but without the terminating zero ("P" for plain) |
.BLOCK n |
reserve n zero-filled words (this is how variables are declared) |
Two traps to note. There is no .ALIGN directive — if you ever need alignment, do it by
hand with .ORG. And in strings, the newline escape must be written lowercase \n; it
becomes CR/LF. By project convention you also write all mnemonics, register names, and labels in
UPPER CASE, comments in mixed case, and follow the fixed column layout you will see in every
file (label in column 1, instruction indented, a right-hand comment column). None of that is
enforced by the assembler, but matching it keeps your diffs clean and your code recognizable to
the next porter.
With QNICE and its assembly in hand, we can turn to the thing that makes writing this firmware
practical rather than painful: the Monitor, and the art of testing a new m2m-rom.asm in
seconds instead of the forty-minute round-trip of a full FPGA build.
Here is the problem this part solves. Your firmware, m2m-rom.asm, is baked into the FPGA
bitstream. Naively, every change means a full re-synthesis — place, route, timing, the works —
which on an Artix-7 can take the better part of an hour. Iterate that way and you will write
three versions a day and hate every one of them. M2M offers a far better loop: synthesize
once, then reload each new firmware build into the running FPGA over a cable in a few
seconds, never touching Vivado again until you are done. The tool that makes this possible is
the Monitor, so we meet it first.
The Monitor (its program is called qmon, "QNICE monitor") is QNICE-FPGA's tiny operating
system. It is easiest to understand as a thing with three faces:
-
A callable library. This is the face you already met:
SYSCALL(puts, 1)and its siblings are Monitor routines. The Monitor provides string, maths, memory, and — crucially — SD-card/FAT32 functions that the Shell leans on heavily (Part 6). Every release build of the Shell includes the Monitor purely so thoseSYSCALLs resolve. Think of this face as the machine's BIOS: low-level services other code calls. -
An interactive console. When QNICE boots the Monitor as its main program, it presents a
command line — the
QMON>prompt — over the serial port. From there a human can peek and poke memory, load a program, and run it. This is the face that powers the development loop. -
A boot ROM. The Monitor sits at address
0x0000, so it is literally what a bare QNICE runs at power-on.
These are not three programs; they are three ways of using one small ROM. The development loop uses faces 2 and 3; the Shell uses face 1.
Connect a serial terminal to the MEGA65 — 115,200 baud, 8-N-1, no flow control — and, when QNICE is running the Monitor, you are greeted by:
Simple QNICE-monitor - Version 1.61 [WIP] (Bernd Ulmann, sy2002, September 2022)
QMON>
You type a command as one or two letters. Commands are organized into four groups, and typing
H prints the built-in cheat sheet:
-
C — control:
Cold-start,Halt,Run, clear-Screen. -
M — memory:
Change,Dump,Examine,Fill,Load,Move, diSassemble. -
F — file (SD card): list
Directory,Change directory,Load,Run. - H — help.
So M then E examines one memory word (it asks for an address and prints the word there);
M then C changes a word (address, then old value shown, then new value); C then R
runs a program (it asks for an address and jumps there). Every number you type is
hexadecimal. One thing that surprises newcomers: there is no register-dump command — the
Monitor lets you inspect memory and disassemble code (M S), but to see CPU state you read the
memory-mapped registers or reach for Vivado's logic analyzer.
The two commands that matter most for firmware work are M L and C R:
QMON> ML
LOAD - ENTER ADDRESS/VALUE PAIRS, TERMINATE WITH CTRL-E
M L (memory load) then sits reading pairs of hex words — an address and a value — and
storing each value at its address, until you press CTRL-E. That "address value" stream is
exactly the format the assembler emits as a .out file. In other words, you can paste an
assembled program straight into the terminal and it lands in QNICE's RAM. Then C R 8000 runs
it. Hold that thought — it is the whole trick.
M2M gives you two independent switches. One decides what lives in the FPGA's boot ROM; the other decides whether your firmware runs from that ROM or is streamed into RAM. Understanding how they combine is what unlocks the fast loop.
Switch 1 — QNICE_FIRMWARE, in CORE/vhdl/globals.vhd. This VHDL constant chooses which
.rom file Vivado bakes into QNICE's boot ROM at synthesis time:
constant QNICE_FIRMWARE_MONITOR : string := "../../../M2M/QNICE/monitor/monitor.rom"; -- debug/development
constant QNICE_FIRMWARE_M2M : string := "../../../CORE/m2m-rom/m2m-rom.rom"; -- release
-- Select firmware here
constant QNICE_FIRMWARE : string := QNICE_FIRMWARE_M2M;Pick QNICE_FIRMWARE_MONITOR and the FPGA boots straight to the QMON> prompt. Pick
QNICE_FIRMWARE_M2M (the default) and it boots your Shell. Because it selects a file to bake
into the bitstream, changing this switch requires a re-synthesis — but you only ever do that
twice: once to enter development mode, once to leave it.
Switch 2 — #define RELEASE, in CORE/m2m-rom/m2m-rom.asm. This assembler define decides
where your firmware is built to live:
#define RELEASE
- With
RELEASEdefined, the firmware is assembled as a self-contained ROM image: it starts at address0x0000, includes the whole Monitor itself (so theSYSCALLs work), and auto-starts atSTART_FIRMWARE, with variables placed in RAM from0x8000up. This is the image you flash for a release. - With
RELEASEundefined, the firmware is assembled to run in RAM: it starts at0x8000and expects the Monitor to already be present in ROM. This is a plain program you canM Linto memory and run — no Monitor bundled in, because the Monitor is already there.
Now watch the two switches click together into a loop that touches Vivado exactly once:
flowchart TD
S["Set QNICE_FIRMWARE = MONITOR<br/>synthesize ONCE, flash over JTAG"] --> Q["FPGA boots to QMON> over serial"]
Q --> A["Edit m2m-rom.asm<br/>(comment out #define RELEASE)"]
A --> B["make_rom.sh<br/>assembles m2m-rom.out"]
B --> C["Load into RAM:<br/>M/L paste, or QTransfer"]
C --> D["QMON> C R 8000<br/>run and test on real hardware"]
D --> A
The synthesized bitstream holds the Monitor in ROM, unchanged, all day. Each firmware
iteration is just: edit the assembly, run make_rom.sh to produce m2m-rom.out, push those
address/value pairs into RAM, and C R 8000. On macOS make_rom.sh even copies the .out to
your clipboard, so "load it" is literally: type M L, paste, press CTRL-E. Seconds, not an
hour. When you are finally happy, you flip both switches back — re-#define RELEASE, set
QNICE_FIRMWARE_M2M — and synthesize the one bitstream you actually ship.
QTransfer — the reliable upgrade over pasting. Pasting a long .out into a terminal with no
hardware flow control (which the MEGA65 lacks) can drop bytes on a big firmware. qtransfer
fixes that: it is a small tool (qtransfer.out) you load into QNICE once, which then receives
your firmware from a host program in CRC16-checked bursts, catching any corruption. You load
qtransfer itself the first time via M L (or from SD), and thereafter prefer it over raw
pasting. Same idea — stream a fresh build into RAM and run it — but robust for real work.
The development loop above boots the Monitor instead of the Shell. But sometimes you want to inspect a live, running Shell — the release firmware, misbehaving on real hardware. For that M2M ships a backdoor: hold Run/Stop + Cursor-Up, and while holding them, press Help.
Under the hood this is a routine called CHECK_DEBUG, which the Shell's main loop calls on
every iteration. It watches the framework keyboard register for exactly that three-key
combination and, when it sees it, hands control to the Monitor. What happens next depends on
which build you are running:
-
In a release build, it enters a special "soft" Monitor entry that first saves the stack pointer and status register, and prints two resume addresses on the serial console — one to continue exactly where the Shell left off, one to restart the Shell from the top:
Entering MiSTer2MEGA65 debug mode. Press H for help and press C R 1C1F to return to where you left off and press C R 1957 to restart the Shell. QMON>You can now
M E/M Cyour way around memory-mapped registers — pokeM2M$CFM_DATAto flip a menu bit, read a device register, watch a value — and thenC R 1C1Fto resume the Shell as if nothing happened. (That resume-cleanly behavior is why the soft entry bothers to preserve SP and SR; the ordinary Monitor CTRL-E warm-start would reset them and you could not return.) -
In a debug/RAM build, the same chord simply exits back to
QMON>; you restart the Shell rather than resuming.
This backdoor is also where the framework lands you on a fatal error (Part 8): the fatal handler prints its message to screen and serial and then quits to the Monitor, so if you have a serial cable attached you can investigate the wreckage.
Real hardware is not the only place to run QNICE code, and often not the fastest. QNICE-FPGA ships a cycle-accurate emulator that runs on your PC, and it has a headless batch mode built for exactly the kind of tight, scriptable testing that firmware logic deserves. This matters because of a rule worth adopting as a mantra:
"It assembles" is not "it works." Any non-trivial QNICE logic — sorting, parsing, string handling, a new data structure — should be proven in the emulator before you ever burn it into a bitstream.
The pattern is a small self-contained testbed that includes the module under test, feeds it
fixed data, prints results with SYSCALL(puts) / SYSCALL(puthex), and exits. The framework
ships several you can copy — llist_test.asm, dirbrowse_test.asm, keyboard_test.asm in
M2M/rom/. You assemble the testbed and run it under the emulator with the Monitor loaded
alongside (so the SYSCALLs resolve):
# assemble the testbed
( cd M2M/rom && ../QNICE/assembler/asm llist_test.asm )
# run it headless: -b loads the .out files, sets SP like the Monitor cold start,
# sets PC to the given hex entry address, and runs until HALT / error / EOF
M2M/QNICE/emulator/qnice -b 0x8000 M2M/QNICE/monitor/monitor.out M2M/rom/llist_test.out < /dev/null
The exit code tells a script what happened: 0 for a clean HALT or end-of-input, 1 for an
emulation error, 130 for CTRL-C. Three gotchas are worth knowing before they bite:
-
The batch entry address is hex, but the emulator's interactive
Q>shell reads bare numbers as decimal. In batch mode-b 0x8000is unambiguous; in the interactive shell,RUN 8000would start at0x1F40. When in the interactive shell, writeRUN 0x8000. -
The interactive
RUNdoes not set the stack pointer (batch mode does, toVAR$STACK_START=0xFEEB, mimicking the Monitor's cold start). A program launched interactively with an uninitialized SP will push its first return address into the I/O page and derail. Prefer batch mode for automated runs. -
A program that never reads stdin can loop forever, and the EOF logic cannot stop it. Wrap
headless runs in an external timeout (
timeouton Linux; on macOS, a Pythonsubprocess(..., timeout=...)), or you will hang your CI.
Between the on-hardware Monitor loop (§3.3) and the headless emulator loop (§3.5) you have two
complementary ways to iterate firmware without ever waiting on Vivado — the emulator for logic
you can feed fixed inputs, the hardware for anything that touches real devices, video, or
timing. With the tools in place, we can finally open the file all of this has been leading up
to: m2m-rom.asm.
m2m-rom.asm is the one firmware file that belongs to your core. It lives in
CORE/m2m-rom/, it is the top of the assembly (everything else is #included from it), and it
is where you spend all your firmware effort. Yet in a fresh template it is astonishingly short,
because almost all the behavior lives in the framework's M2M/rom/ files that it pulls in. This
part shows you its shape, then follows what happens when it runs.
Strip m2m-rom.asm to its skeleton and you see five regions:
; --- 1. Firmware: pull in the framework --------------------------------
#define RELEASE ; ROM build (Part 3); comment out to debug in RAM
#include "../../M2M/rom/main.asm" ; Monitor + bootstrap; ends by jumping to START_FIRMWARE
#include "../../M2M/rom/shell.asm" ; the Shell: menu, browser, mounting, the IO loop
; --- 2. Entry point ----------------------------------------------------
START_FIRMWARE RBRA START_SHELL, 1 ; "run the Shell." Replace this to run your own firmware.
; --- 3. Your callbacks (Part 5) ---------------------------------------
; Trivial stubs in the template — override only the ones your core needs:
SUBMENU_SUMMARY ... ; live summary for a submenu headline
FILTER_FILES ... ; show or hide a file in the browser
PREP_LOAD_IMAGE ... ; validate an image; return its type
PREP_START ... ; one-time prep before the core runs
OSM_SEL_PRE ... ; before a menu selection is applied
OSM_SEL_POST ... ; after a menu selection is applied
CUSTOM_MSG ... ; a core-specific browser message
HANDLE_CORE_IO ... ; per-iteration background time slice
; --- 4. Your constants and strings ------------------------------------
END_OF_ROM .DW 0 ; marks the end of the ROM image
; --- 5. Variables, heap, stack in RAM (sizing in Part 8) --------------
.ORG 0x8000 ; RAM begins here
#include "../../M2M/rom/shell_vars.asm" ; the Shell's own variables
MENU_HEAP_SIZE .EQU 1024 ; the memory budget you tune (Part 8)
HEAP_SIZE .EQU 28672
...
Read that top-to-bottom and the design philosophy is plain. Region 1 says "give me the whole
framework." Region 2 says "and run it." Region 3 — the callbacks — is the only part most porters
ever write, and it is the subject of Part 5. Region 4 is where you add any core-specific strings
or constants your callbacks reference, ending with the sentinel label END_OF_ROM (the Shell
uses it to compute how much ROM you have left). Region 5, below the 0x8000 origin, is the RAM
side: the Shell's variables, then the heap and stack sizes that you balance to trade "more files
in the browser" against "a bigger menu" — the topic of Part 8.
The two #includes do the heavy lifting. main.asm is the bootstrap: in a release build it
places everything at 0x0000, embeds the Monitor (so your SYSCALLs resolve — Part 3), sets up
the stack, and jumps to START_FIRMWARE. shell.asm is the trunk of the Shell and itself
#includes the dozen units that implement the menu, the browser, mounting, ROM loading, and so
on. You rarely edit either; you use them through the callbacks and the library (Part 6).
START_FIRMWARE deserves a note. It is nothing but RBRA START_SHELL, 1 — an unconditional
jump into the framework's Shell. That indirection is deliberate: it is the single seam where a
very advanced core could replace the entire Shell with its own firmware, simply by branching
somewhere else. Almost nobody does, but it is worth knowing the door exists.
When the FPGA comes up, QNICE begins executing at 0x0000, runs the bootstrap in main.asm,
and lands at START_FIRMWARE, which jumps to START_SHELL. START_SHELL never returns — it is
entered with a plain branch, not a call, because it is the top of the program. What it does,
in order, is: wait briefly for the SD card to stabilize, clear the framework's file handles, and
then initialize the Shell's libraries in a strict, order-sensitive sequence:
RSUB SCR$INIT, 1 ; read screen/OSM geometry into the SCR$ variables
RSUB FRAME_FULLSCR, 1 ; draw the full-screen frame
RSUB VD_INIT, 1 ; set up the virtual-drive system
RSUB CRTROM_INIT, 1 ; set up the CRT/ROM loader system
RSUB KEYB$INIT, 1 ; initialize the keyboard library
RSUB HELP_MENU_INIT, 1 ; build the menu; load defaults into M2M$CFM_DATA
RSUB CRTROM_AUTOLOAD, 1 ; auto-load any mandatory ROMs from SD
...
RSUB RP_SYSTEM_START, 1 ; reset management — must run AFTER HELP_MENU_INIT
The order is not arbitrary, and the comments in the source call out the one constraint you must
respect if you ever touch this: reset management (RP_SYSTEM_START) must run after
HELP_MENU_INIT. The reason is a small chain of cause and effect. HELP_MENU_INIT is what
populates the 256-bit menu-state register M2M$CFM_DATA with your menu's default selections
(read from config.vhd). Some of those defaults — a clock-speed choice, say — reach the core the
instant it comes out of reset. So the menu defaults must already be in place before the reset
manager lets the core start; otherwise the core would boot for a moment with the wrong settings.
After the libraries are up, the Shell optionally shows your welcome screen, then calls your
PREP_START callback — the last chance to prepare things while the core is still held in reset
(Part 5) — and finally releases the core from reset and connects the keyboard and joysticks. At
that moment the emulated machine springs to life, and the Shell drops into its main loop.
The Shell has no interrupts (Part 1) and no scheduler. It is a single loop that runs forever, polling for things to do:
MAIN_LOOP RSUB HANDLE_IO, 1 ; service the core's IO (disk blocks, cache flush)
RSUB KEYB$SCAN, 1 ; sample the keyboard
RSUB KEYB$GETKEY, 1 ; fetch one fresh keypress, if any
RSUB CHECK_DEBUG, 1 ; the Run/Stop + Cursor-Up + Help backdoor (Part 3)
RSUB HELP_MENU, 1 ; if that key was Help: open the On-Screen-Menu
RSUB LOG_COREINFO, 1 ; once, a few seconds in: log core info to serial
RBRA MAIN_LOOP, 1
Everything the Shell does at rest is here. KEYB$SCAN/KEYB$GETKEY turn the raw keyboard into
one clean keypress; HELP_MENU notices when that key is Help and, only then, opens
the whole On-Screen-Menu (drawing it, running it, and applying your selections — which is where
the OSM_SEL_* callbacks fire). Mounting a disk is not polled here; it happens from inside the
menu, when you pick a mount item and the browser opens.
The most important line for the rest of this article is the first one: HANDLE_IO. This is
the framework's per-iteration IO service. On every pass it checks whether the SD card changed,
then walks each virtual drive, satisfying the core's block reads and writes and doing the
background write-back of any dirty disk cache — and it calls your HANDLE_CORE_IO callback
(§5.6), handing your core its own slice of time on every pass. The virtual-drive part is the
machinery that lets a mounted disk image behave, to the emulated machine, like a real spinning
drive.
And here is the subtle, load-bearing fact — remember it for Part 7. HANDLE_IO is not only
called from MAIN_LOOP. It is also called from inside every blocking wait loop in the Shell:
while the On-Screen-Menu is open, while the file browser is up, while a "press Space to continue"
screen waits for you. The Shell deliberately keeps pumping IO even while a modal screen sits in
front of the user, so a disk write started before you opened the menu still finishes while you
are in it. That single design choice — IO keeps flowing even during modal screens — is exactly
the seam the Amiga core reaches through to achieve real-time floppy write-back, which is where
Part 7 will take us. But first, the callbacks.
This is the part most porters live in. A callback is a labelled routine in your
m2m-rom.asm that the Shell calls — not the other way around — at a specific, well-defined
moment: when it is about to draw a submenu, when it meets a file in the browser, when it has
opened an image you asked to mount, just before it starts the core, and after you change a menu
item. The framework ships each callback as a trivial stub that returns R8 = 0, meaning "no
opinion, do the default." You override the handful your core actually needs. That is, to a first
approximation, the whole job of porting the firmware.
The callbacks in the table below follow the calling convention from Part 2: arguments arrive in
R8, R9, R10 (and sometimes R11), results go back in R8/R9, and the routine brackets
its work with INCRB/DECRB. What differs is what the registers mean and — importantly — what the Shell
does with a non-zero return. There are two conventions, and one sharp distinction worth learning
before the details:
| Callback |
R8 = 0 returns |
non-zero R8 returns |
on "error" |
|---|---|---|---|
SUBMENU_SUMMARY |
use the default headline | a replacement headline string | advisory |
CUSTOM_MSG |
use the default message | a replacement message string | advisory |
FILTER_FILES |
show the file | (R8 = 1) hide the file |
advisory |
PREP_LOAD_IMAGE |
OK; R9 = image type |
a load error; R9 = error string |
non-fatal — user re-picks |
PREP_START |
OK | error message + code | fatal — boot aborts |
OSM_SEL_PRE |
OK | error message + code | fatal |
OSM_SEL_POST |
OK | error message + code | fatal |
The sharp distinction is that last group. PREP_START, OSM_SEL_PRE, and OSM_SEL_POST treat
a non-zero return as a fatal error that halts the machine (Part 8). That means they cannot
gracefully reject anything by returning an error. If one of them wants to change or veto a menu
choice, it does so not by returning an error but by mutating the menu state directly, with a
helper called M2M$FORCE_MENU (Part 6). Keep that in mind; it explains code that would
otherwise look strange.
The seven in the table are all event-driven: the Shell calls each one in response to a specific
action. There is an eighth callback of a different character — HANDLE_CORE_IO — which the Shell
calls on every iteration, to give your core a slice of background time. Because it is not tied
to any single event, we meet it last (§5.6).
We will walk the seven event-driven callbacks in the order a user experiences them — presenting the menu, browsing for a file, mounting it, starting the core, and reacting to selections — and then the per-iteration eighth.
When a menu item opens a submenu, its line in the parent menu can contain a %s placeholder,
and the Shell fills that in. By default it substitutes the label of whichever radio item inside
the submenu is currently selected — so "Model: %s" becomes "Model: PAL". SUBMENU_SUMMARY
lets your core compute something smarter.
Contract. In: R8 = the headline string containing %s; R9 = a pointer to this menu item
inside the live menu-groups structure; R10 = an end-of-menu marker (if R9 equals R10, you
have walked off the end). Out: R8 = 0 to accept the default, or a pointer to a brand-new
headline string.
The C64 in action. The C64 core turns two flat submenu headlines into live status readouts.
For the Model submenu it reads the actual radio-button state — which machine (PAL/NTSC), which
turbo scheme (off / C128 / smart), which turbo speed (2x/3x/4x) — and, when turbo is on,
composes "Model: PAL C128 2x" so you see machine, scheme, and speed at a glance without opening
the submenu. For the Kernal submenu it produces "Kernal: Jiffy 1541+1581" to advertise exactly
which JiffyDOS drive ROMs are installed. The neat part is how: it builds those strings by
copying the labels straight out of the running menu, so if you rename a menu item in
config.vhd, the summary updates itself with no code change.
When you pick a "mount" or "load" item, the Shell opens a file browser over the SD card. Two callbacks tailor that experience.
FILTER_FILES is asked, for every entry the browser encounters, whether to show it. In:
R8 = the filename in upper case; R9 = 0 for a file, 1 for a directory; R10 = the context
(a CTX_* value telling you why the browser is open — mounting a disk vs. loading a ROM); and
R11 = the menu group id of the item that opened the browser. Out: R8 = 0 to show the file,
1 to hide it.
The C64 uses this to keep the list uncluttered and correct: when you are mounting a disk it shows
only .D64 and .D81 images (never .G64, which this core cannot handle); and because the same
callback is told which item opened the browser, the "Load PRG" item filters to .PRG files
while the "Load CRT" item filters to .CRT cartridges — one callback, three behaviors, selected
by context and group id. The extension test itself is a one-liner using the library helper
M2M$CHK_EXT (Part 6), which returns its yes/no in the carry flag. The Amiga core does the
analogous thing for .adf floppy images.
CUSTOM_MSG handles the sad case: what if the filter hid everything and the browser is empty?
Rather than show a generic "nothing here," the Shell asks your core for a better message. In:
R8 = the situation (a CMSG_* constant — here, "browser found nothing"); R9 = the context.
Out: R8 = 0 for the default message, or a pointer to your own. The C64 returns a genuinely
helpful multi-line note — that this core uses D64/D81 images, that you should copy at least one
onto your SD card, and that a /c64 folder will make the browser start there — but only for
the empty-disk-browser situation, deferring to the framework's default everywhere else.
You have picked a file; the Shell has opened it; now, before it streams the image into memory
and tells the core to mount it, the Shell hands you the open file so you can inspect it. This is
PREP_LOAD_IMAGE, and it does two jobs: sanity-check the image, and classify it.
Contract. In: R8 = an open FAT32 file handle (you may move its read pointer — e.g. to skip
a header); R9 = the context; R10 = the triggering menu group id. Out: R8 = 0 on success
with R9 holding a 2-bit image type, or a non-zero error code with R9 optionally pointing
at an error message. This error is non-fatal: the Shell shows your message and lets the user
choose another file.
That 2-bit image type is not cosmetic — it is forwarded to the core through the mount strobe, and it is how the emulated machine decides what kind of drive to spin up. In the C64, returning "D64" tells the hardware to be a 1541; returning "D81" tells it to be a 1581.
The C64 as a worked example. Its PREP_LOAD_IMAGE validates a disk image by size, because
a real Commodore disk has exact byte counts. A D64 must be 174,848 bytes (a 35-track disk) or
196,608 (40 tracks); a D81 must be exactly 819,200 bytes (0x000C8000). It deliberately rejects
"error-info" D81 variants (like 822,400 bytes) because those would feed the 1581 a wrong geometry
and corrupt data. For .crt cartridges it instead checks the size against a ceiling derived from
the HyperRAM memory map, so an oversized cartridge cannot overflow its staging pool. This is also
a lovely little lesson in QNICE comparison: a 32-bit size is compared word by word with CMP and
conditional branches, testing the N flag ("destination less than source") and Z flag
("equal") — the idiom from Part 2, applied for real:
CMP C64_CRT_MAX_SIZE_HI, R1 ; compare the size's high word against the limit's high word
RBRA _PREP_LI_ROM_OK, N ; high word below the limit -> comfortably OK
RBRA _PREP_LI_CRT_BIG, !Z ; high word above the limit -> too big
; (equal high words fall through to check the low word)
The Amiga core's version range-checks the ADF's size and — a preview of Part 7 — first force-flushes the currently-mounted floppy before the new image overwrites it in memory.
PREP_START is called exactly once, at the end of boot, when everything is ready but the core is
still held in reset: the libraries are up, your settings are loaded, and the menu defaults are
already in M2M$CFM_DATA. It is the place for one-time setup that must be in effect before the
core's very first cycle. In: nothing. Out: R8 = 0, or an error string with R9 = a code — and
remember, an error here is fatal, aborting the boot, because a core that cannot be prepared
correctly should not run at all.
The C64 does two things here. First, it pushes your saved HDMI scaling filter into the scaler —
before the core leaves reset, so the very first frame that reaches your TV already looks right and
you never see a flicker of the wrong filter. Second, it runs a "JiffyDOS sanity gate": if you
selected JiffyDOS but the required ROM files are not on your SD card, it prints a diagnostic to
the serial console and quietly falls back to the standard Kernal — for this session only. The
subtlety is worth savoring: it changes the setting with M2M$SET_SETTING, which writes only the
in-memory menu mirror and not the SD settings file, so your saved JiffyDOS preference survives
a temporary missing-ROM boot and comes back the moment you restore the ROMs. The Amiga core uses
its PREP_START similarly, applying both the saved HDMI filter and saved screen-centering offsets
before un-reset.
These two share one contract and differ only in timing, and that difference is the entire
reason both exist. Both fire when you select something in the On-Screen-Menu. In: R8 = the menu
group you selected; R9 = the selected item within that group (for a single-select group, 0 or
1); R10 = which key you used (OPTM_KEY_SELECT, normally Return, or OPTM_KEY_SELALT, normally
Space). Out: R8 = 0 or a (fatal) error.
The timing:
-
OSM_SEL_PREruns before the framework applies your selection to the hardware (before it copies the new menu bit intoM2M$CFM_DATA). -
OSM_SEL_POSTruns after the framework has applied it, when the hardware bit already reflects your choice.
So PRE is your chance to intercept — to change or undo a selection before the core ever sees
it — and POST is your chance to react to a change that is already live.
What the C64 does in PRE. Two guard-rails. First, when you pick "Load CRT," it quietly
switches the expansion-port mode to "simulated cartridge" (and soft-resets the C64) before the
browser opens — because otherwise the running machine would have its hardware cartridge port
vanish mid-browse and hang. Second, and more elegantly: the "use internal 1581 drive" toggle is
idle-gated. Switching that toggle mid-operation could corrupt a disk, so OSM_SEL_PRE peeks at
a live diagnostic register and, if the drive is actually reading, stepping, or spinning — or if
its write-back cache still holds unsaved data — it snaps the switch straight back to where it was.
Because this happens in PRE, before the framework commits the choice to the FPGA, the hardware
never even sees the disallowed flip. You physically cannot switch drives out from under a write
in progress. That is the payoff of the PRE/POST split.
What the C64 does in POST. It reacts to changes that are now live. Change the HDMI filter and
it re-loads the coefficient table with no reset — the C64 keeps running and you see the new filter
from the very next frame. But change something that only takes effect on a clean boot — the Kernal,
the expansion-port mode, or the REU memory expansion — and it fires a soft reset that reboots the
C64 while deliberately keeping any loaded cartridge in place, so the cartridge re-launches
automatically. Different settings, different responses, all keyed off the group id you changed.
The Amiga core uses the same two callbacks as a matched bracket, which is a nice second reason
the pair exists. Its OSM_SEL_PRE raises a flag meaning "a menu sub-activity is now running" — the
file browser, or a help screen — and its OSM_SEL_POST clears it once that sub-activity returns.
Because the two hooks wrap the entire selection, the core's background floppy-eject gesture
(Part 7) can tell whether a Space press belongs to the menu itself or to a sub-screen opened from
it. Same PRE/POST timing, used not to intercept a value but to fence a span of time.
The seven callbacks so far are all reactive: the Shell calls each one when a specific event
happens — a menu draw, a file met in the browser, a mount, a boot, a selection. HANDLE_CORE_IO
is different in kind. The Shell calls it on every single iteration of its work — both the main
loop and every blocking wait loop (Part 4) — handing your core a small, regular slice of time to
do background work of its own.
Contract. In: nothing. Out: nothing — and, unusually, it must preserve every register, so
it brackets its body with SYSCALL(enter, 1) / SYSCALL(leave, 1) (Part 6); it is interrupting
Shell code mid-computation and must leave no trace. Three rules follow from when it runs:
- It runs constantly — even while the menu, the browser, or a "press Space" screen is open — so a background job keeps progressing no matter what the user is doing.
- It must return quickly. This is cooperative multitasking: do at most a tiny, bounded slice of work per call, then yield.
- It may repoint the porthole (
M2M$RAMROM_DEVand the window) if it needs a device, but must save and restore the selection (SAVE_DEVSEL/RESTORE_DEVSEL, Part 6) so the Shell never notices.
What is it for? Anything your core must do continuously in the background that lives outside
the framework's own virtual-drive machinery: a write-back cache for a storage device the framework
does not manage, a periodic status poll, a hardware refresh. One practical note follows from that
"every iteration" reach: HANDLE_CORE_IO can fire from the earliest boot-time wait loops — before
PREP_START runs, when your RAM variables may still be undefined — so initialize any state it
touches as early as possible (from START_FIRMWARE), not in PREP_START.
This one callback is quietly the most powerful of the eight: a heartbeat that keeps ticking behind
every screen is exactly what you need to build a capability the framework never shipped. The Amiga
core uses it to give an emulated floppy a writable disk that saves back to the SD card in real
time — the case study of Part 7, and the best demonstration of what HANDLE_CORE_IO makes
possible.
Those eight callbacks are the whole contract between a core and the Shell — seven reactive, one continuous. Master them and you can make the framework's menu, browser, mount flow, and background IO behave exactly as your machine needs, without touching a line of the framework itself. To do anything inside a callback, though, you need the framework's library of ready-made routines. That is Part 6.
You will not write string comparisons, hex conversions, or SD-card reads by hand. Both QNICE and M2M hand you a library of ready-made routines, and this part is your map to them. Treat it as a reference to skim now and return to later; the authoritative signature for any routine is always the comment block above it in the source.
The routines you can call come from two places, and you call them with two different mnemonics:
-
The QNICE Monitor standard library — general-purpose services (I/O, strings, maths,
memory, SD/FAT32). You call these with
SYSCALL(name, 1), which routes through the Monitor's fixed jump table (Part 3). By convention these use lower-case names, likeSYSCALL(puts, 1). -
The M2M Shell library — the M2M-specific routines that know about menus, the OSM screen,
the keyboard, virtual drives, and the porthole registers. You call these with an ordinary
RSUB LABEL, 1, likeRSUB M2M$CHK_EXT, 1. Their labels carry a subsystem prefix and a$:M2M$…,SCR$…,KEYB$….
Both obey the calling convention from Part 2 — arguments in R8–R12, results in R8–R12 or
the carry flag.
Before the reference tables, here is the short list that the real C64 and Amiga cores lean on in their callbacks. If you learn only these, you can write most of a port:
-
M2M$GET_SETTING/M2M$SET_SETTING/M2M$FORCE_MENU— read, quietly set, or forcibly change (with on-screen feedback) the on/off state of a menu item, addressed by its group index. These are how a callback inspects and steers the menu.SETwrites only the in-memory menu mirror;FORCEalso updates the visible menu and the hardware bit and handles radio-group exclusivity. -
M2M$CHK_EXT— does this filename end in this extension? Answer in the carry flag. The heart ofFILTER_FILES. -
M2M$RPL_S— splice a string into the%sof another (with an ellipsis if it overflows the width). The heart ofSUBMENU_SUMMARYand custom messages. -
SAVE_DEVSEL/RESTORE_DEVSEL— save and restore the porthole selection (M2M$RAMROM_DEVand the window). Essential whenever a callback pokes a device: leave the Shell's selection as you found it. -
SCR$PRINTSTR/SCR$PRINTSTRXY— print a string to the OSM screen (at the cursor, or at explicit coordinates). -
KEYB$GETKEY— get the next fresh keypress as anM2M$KEY_*value. -
SYSCALL(puts, 1)— print a string to the serial console (and, in M2M, optionally the screen). -
SYSCALL(memcpy, 1)/SYSCALL(memset, 1)— block copy / fill. -
FATAL— abort with a message (Part 8).
Called via SYSCALL(name, 1). A curated selection of the most useful; the full set is documented
in the auto-generated M2M/QNICE/doc/monitor/doc.pdf.
| Group | Call | Does | In → Out |
|---|---|---|---|
| I/O | SYSCALL(puts, 1) |
print a zero-terminated string |
R8 = ptr |
| I/O | SYSCALL(putc, 1) |
print one character |
R8 = char |
| I/O | SYSCALL(getc, 1) |
read one character | → R8 = char |
| I/O | SYSCALL(crlf, 1) |
print a newline | — |
| I/O | SYSCALL(puthex, 1) |
print a word as 4 hex digits |
R8 = value |
| I/O | SYSCALL(gets_s, 1) |
read a line into a bounded buffer |
R8 = buf, R9 = size |
| String | SYSCALL(strlen, 1) |
string length |
R8 = ptr → R9 = len |
| String | SYSCALL(strcmp, 1) |
compare two strings |
R8,R9 → R10 = sign |
| String | SYSCALL(strcpy, 1) |
copy a string |
R8 = src, R9 = dst |
| String | SYSCALL(strstr, 1) |
find a substring |
R8,R9 → R10 = ptr or 0 |
| String | SYSCALL(str2upper, 1) |
uppercase in place |
R8 = ptr |
| String | SYSCALL(h2dstr, 1) |
32-bit value → decimal ASCII |
R8/R9 = lo/hi, R10 = buf |
| Math |
SYSCALL(mulu, 1) (MTH$MULU) |
unsigned 16×16 multiply |
R8,R9 → R11:R10
|
| Math |
SYSCALL(divu, 1) (MTH$DIVU) |
unsigned divide |
R8,R9 → R10 quot, R11 rem |
| Math | SYSCALL(in_range_u, 1) |
unsigned range test |
R8,R9,R10 → carry |
| Memory |
SYSCALL(memcpy, 1) (MEM$MOVE) |
copy N words |
R8 src, R9 dst, R10 count |
| Memory |
SYSCALL(memset, 1) (MEM$FILL) |
fill N words |
R8 addr, R9 count, R10 val |
| Misc |
SYSCALL(enter, 1) / SYSCALL(leave, 1)
|
heavy register save / restore | — |
| Misc | SYSCALL(wait, 1) |
busy-wait ~R8 ms |
R8 = delay |
| Misc | SYSCALL(exit, 1) |
return to the Monitor | — |
Two notes. enter/leave are a heavier alternative to INCRB/DECRB that also preserve
R8–R12 (useful when a routine must not disturb any register — the per-iteration
HANDLE_CORE_IO callback uses them). And QNICE has no CPU multiply/divide; the MTH$… calls drive the EAE
co-processor (Part 1) — memcpy is MEM$MOVE, a forward copy, so it is not safe for overlapping
regions where the destination is above the source.
Called via RSUB. Grouped by subsystem; again, a curated selection.
| Family | Routine | Does |
|---|---|---|
Settings (tools.asm) |
M2M$GET_SETTING |
read a menu item's 0/1 state by group index |
M2M$SET_SETTING |
set it in the in-memory mirror only | |
M2M$FORCE_MENU |
set it everywhere (screen + hardware + radio exclusivity) | |
Files/strings (tools.asm) |
M2M$CHK_EXT |
filename-has-extension test → carry |
M2M$RPL_S |
replace %s in a source string (width-clamped) |
|
WORD2HEXSTR |
word → hex string | |
Porthole (tools.asm) |
SAVE_DEVSEL / RESTORE_DEVSEL
|
save / restore device + window selectors |
Logging/timing (tools.asm) |
LOG_STR |
print a string to the serial console |
WAIT1SEC / WAIT333MS
|
busy-wait 1 s / ⅓ s | |
Screen (screen.asm) |
SCR$PRINTSTR |
print at the cursor |
SCR$PRINTSTRXY |
print at explicit x/y | |
SCR$CLR / SCR$GOTOXY
|
clear screen / move cursor | |
SCR$OSM_M_ON / SCR$OSM_OFF
|
show / hide the OSM overlay | |
Keyboard (keyboard.asm) |
KEYB$SCAN / KEYB$GETKEY
|
sample the keyboard / fetch a keypress |
Fatal (shell.asm) |
FATAL |
print a message and quit to the Monitor |
Beyond these, three heavier subsystems expose their own RSUB-callable APIs — the virtual
drives (VD_* in vdrives.asm: mount status, dirty-cache queries, low-level register access),
the CRT/ROM loader (CRTROM_* in crts-and-roms.asm: load status and device-CSR read/write
via CRTROM_CSR_R / CRTROM_CSR_W), and the FAT32/SD file family (via SYSCALL(f32_…)).
Most ports never touch these directly — the Shell drives them for you. But they are exactly the
handles an advanced core reaches for when it wants to do something the framework never shipped,
which is Part 7.
The library has a few names that look like they should exist but do not, or exist somewhere other than you expect. Save yourself the grep:
-
The options-menu engine is
OPTM_, notOPTM$— underscore, no$. There is noOPTM$GET_SETTING; the routine you want for reading a menu item isM2M$GET_SETTINGintools.asm. -
M2M$LOAD_POLYPHASEis not a framework routine. Both reference cores call something by that name, but it is a core-side backport each of them carries in their own copy, not part of the upstream Shell. Do not reach for it as if the framework provides it; if you need it, you port it in yourself (and it is slated to become a real framework feature in a future version). -
The I/O library is an M2M fork. M2M compiles its own
io_library_m2m.asmrather than the stock QNICE one, so thatSYSCALL(puts)and friends can route output to the OSM screen as well as the serial line. The labels and behavior you rely on are the same; only know that the framework's copy is the one that matters (the same "M2M shadows the submodule" pattern you see throughout the codebase).
With the toolbox in hand, we can attempt something genuinely ambitious — driving these internals to make the framework do a thing it was never built to do.
Everything so far has been about using the Shell as intended. This part is about reaching past
the intended surface — calling the framework's internal routines directly to build a capability
the framework never shipped. The case study is real and impressive: the Amiga core (AExp) makes
an emulated floppy writable, saving your changes back to the .adf file on the SD card, in
the background, while the machine keeps running — with just one added line in the framework. It
is the best worked example of the technique, and studying it teaches you the whole method.
A real disk drive writes. Emulate one and you must decide what happens to those writes. M2M's
built-in answer is the virtual-drive system (vdrives): the Shell buffers a mounted image in
memory, serves the core's block reads and writes from that buffer, and lazily writes the dirty
buffer back to the SD file when the drive goes idle. This works because a MiSTer disk drive speaks
in blocks — "read logical block 42," "write logical block 42" — which map cleanly onto a file.
The Amiga floppy does not speak in blocks. Its drive controller (Paula) reads and writes a raw
MFM flux stream — the actual magnetic waveform — with no notion of a logical block number.
There is nothing for vdrives to latch onto. So the Amiga core faces a fork: either give up on
writable floppies, or build the write path itself. It chose to build it, and the way it did so is
a small masterclass.
For contrast, note what stock M2M's write-back actually does, because the Amiga borrows its
shape: when a virtual drive's cache is dirty and has been idle long enough (an anti-thrashing
delay), the Shell's FLUSH_CACHE rewrites the whole image from the beginning, dribbling a
configurable number of bytes per HANDLE_IO pass so it never stalls the core, and flushing once
at the end. Lazy, whole-image, idle-triggered, incremental, fatal on error. Hold those five
adjectives.
The Amiga core's design document states the lesson in one sentence: when a framework system does
not fit, separate its mechanism from its pattern. The vdrives mechanism — a decoded-block
bridge — is useless for a flux-stream floppy, so AExp discards it entirely (it configures zero
virtual drives). But the vdrives pattern — background, incremental, anti-thrash-gated,
still-open file handle, fatal on error — is exactly right, so AExp reimplements that pattern
against its own hardware.
Concretely, the FPGA side of AExp decodes the Amiga's MFM writes back into sectors and commits
them into an ADF image held in HyperRAM, marking which tracks changed in a dirty bitmap. That
hardware is exposed to QNICE as an ordinary core device (device id 0x0103, safely in the
core-owned >= 0x0100 range from Part 1) with a small "write-back cache" register block — the
QNICE-domain analogue of what vdrives.vhd provides for a virtual drive. The firmware's job is to
watch that dirty bitmap and stream the changed tracks out to the SD file. Everything below is that
firmware.
The engine that follows is built on the HANDLE_CORE_IO callback from Part 5 (§5.6) — the
per-iteration slice of time the Shell grants your core. Its contract (preserve every register,
return quickly, leave the porthole as you found it) is what makes it safe to run a flusher inside;
and the property that matters most here is the one from §5.6: because HANDLE_IO calls it from
every blocking wait loop as well as the main loop (Part 4), the write-back keeps advancing even
while the user sits in the menu or the browser — a disk write begun before you opened the menu
still finishes while you are in it.
That "every iteration, from the first instant of boot" reach forces one concrete decision. Because
HANDLE_CORE_IO can fire from boot-time wait loops — before PREP_START runs, when RAM variables
are still undefined — AExp seeds all of its write-back state from START_FIRMWARE, the first thing
the firmware does, rather than from PREP_START, where it would be too late.
Here is the subtlest problem, and its solution is the crux of the whole technique. To write to the
.adf file in the background, the firmware needs an open FAT32 file handle for it. The obvious
handle is the one the Shell used to load the image — but the Shell reuses that handle for the
next thing you load, re-opening the same handle structure before your PREP_LOAD_IMAGE even runs.
If the background flusher held onto it, a later load would yank it away mid-write.
The solution is to take a private snapshot of the handle at mount time — a plain memcpy of
the whole 12-word FAT32 handle structure into the core's own storage:
MOVE HANDLE_RM_FILE1, R8 ; the Shell's live mount handle
MOVE ADF_FDH, R9 ; our own private copy
MOVE FAT32$FDH_STRUCT_SIZE, R10 ; all 12 words of it
SYSCALL(memcpy, 1) ; clone it
This works because of a fact about the data: the .adf file is fixed-size and never moves on the
card, so its cluster chain — captured in the snapshot — stays valid for the life of the mount. The
firmware flushes through its own copy, and the Shell can do whatever it likes with the original.
The snapshot is taken exactly once per mount, on the rising edge of the device's "parse complete"
status, which the hook watches for.
The flush itself lives in a routine FLUSH_ADF_STEP, and it is a small state machine that does
at most one step per call and then returns — cooperative multitasking, mirroring the very
FLUSH_CACHE discipline from §7.1. A step is one of two things.
If no track flush is in progress, it looks for work: it scans the dirty-track bitmap for the lowest set bit, and — a lovely detail — it clears that bit before reading the track. Clearing first means that if the Amiga writes to the same track during the flush, the bit re-sets and the track is flushed again on a later pass: torn reads self-heal, for free. It then computes the track's byte offset (each Amiga track is 11 sectors of 512 bytes = 5,632 bytes) and seeks the snapshotted handle there, opening a "track session."
If a track session is active, it streams one 512-byte chunk: it slides the porthole onto the right 4 KW slice of the image in HyperRAM, copies 512 bytes out of that window into the SD file one at a time, and — crucially — flushes immediately:
MOVE ADF_FLUSH_CHUNK, R5 ; 512 bytes in this chunk (one FAT32 sector)
_FADF_WLOOP MOVE ADF_FDH, R8
MOVE @R7++, R9 ; one image byte, read from the HyperRAM window
SYSCALL(f32_fwrite, 1) ; write it to the SD file at the current position
CMP 0, R9
RBRA _FADF_FATAL, !Z ; any FAT32 error is fatal
SUB 1, R5
RBRA _FADF_WLOOP, !Z
MOVE ADF_FDH, R8 ; persist this sector to the card NOW:
SYSCALL(f32_fflush, 1) ; do not let a dirty sector survive the time slice
Why 512 bytes, and why flush after every chunk? This is the detail that separates a working design from a corrupting one. The SD controller has exactly one hardware sector buffer, and it is shared by every piece of code that touches the card — including the Shell's own settings-save routine, which (remember Part 4) can run from the very same wait loops that are polling this flush. If the flusher left a half-filled, dirty sector buffer sitting around between time slices, the settings-save could overwrite it and both writes would corrupt. So each chunk is sized to exactly one 512-byte sector, aligned to a sector boundary, and explicitly flushed before the routine yields. And it costs nothing: that sector was going to be written exactly once anyway — just now instead of later. Because the flush works per track, a change like a file rename that touches one or two tracks writes only a few kilobytes, not the whole 880 KB disk.
The entire write path rests on three SYSCALLs into the QNICE FAT32 library, and each depends on
a capability that the M2M branch of QNICE (dev-V1.61) added specifically to enable disk
write-back:
-
SYSCALL(f32_fseek, 1)— position the handle. Indev-V1.61this seeks relative to the file start (it used to seek from the current position), walking the cluster chain from the beginning — which is exactly why the handle snapshot must include the file's start cluster. -
SYSCALL(f32_fwrite, 1)— write one byte at the current position. It can only overwrite an existing byte; it cannot grow the file. For a fixed-size disk image that is not a limitation — it is the perfect fit. -
SYSCALL(f32_fflush, 1)— write the sector buffer to the card, but only if it is dirty.
That "overwrite existing bytes, same size only" restriction is not an accident the Amiga works around; it is the deliberate, safety-limited SD-write capability that M2M's QNICE branch provides, and a fixed-size disk image is precisely the use case it was built for. The whole feature is, in a sense, that one capability applied with discipline.
Reaching into shared machinery obliges you to leave no trace and to fail safe. The Amiga hook is a model of both:
- Fail safe on a card swap. Before doing anything, the hook checks whether the SD card changed — or even whether the active card slot changed (switching slots in the browser does not raise the normal "card changed" flag). If so, it throws away the snapshotted handle and wipes the dirty state, because writing a card's data to a different card would be catastrophic. The rule it encodes: never write to a card you did not open the file on.
-
Leave the porthole as you found it. The hook and each of its helpers saves the current
M2M$RAMROM_DEV/window selection on entry and restores it on exit, so the Shell code it interrupted never notices that a device was selected underneath it. This isSAVE_DEVSEL/RESTORE_DEVSEL(Part 6) as a discipline, applied rigorously. - Share the slice politely. The same per-iteration budget carries two other background chores — a Space-bar "eject the floppy" gesture, and a once-a-minute reseed of the Amiga's battery-backed clock — and each is written to be register- and porthole-neutral so they compose without stepping on one another or on the flush.
-
Fatal means fatal. Any FAT32 error during the flush calls
FATAL(Part 8). A storage error mid-write is not something to paper over.
Strip away the Amiga specifics and you have a reusable pattern for making the Shell do something it was not built for. When you need a background task that touches shared framework state, this is the shape:
-
Get a heartbeat — a per-iteration hook (
HANDLE_CORE_IO) that the Shell calls from the main loop and every wait loop, so your task progresses even behind modal screens. - Snapshot, don't borrow — clone any framework resource (a file handle, a device selection) that the Shell might reuse out from under you.
- Chunk cooperatively — do a tiny, bounded slice of work per call and yield; never block.
- Respect the shared hardware — understand what else touches the resource (the single SD sector buffer), and flush/restore so no partial state survives your time slice.
- Gate and guard — anti-thrash so you do not thrash the medium; guard against the state changing under you (a swapped card); leave every register and selection as you found it.
-
Fail loudly — a corrupted write is worse than a halt; call
FATAL.
Follow that and you can build capabilities the framework never imagined, without forking it. The
Amiga's writable floppy is that recipe applied in earnest — the HANDLE_CORE_IO heartbeat, a
snapshotted handle, cooperative chunking, and careful manners, composed into a feature that feels
native to the Shell. And that is how the framework itself grows: a technique proven in one core
becomes a capability every core can reuse (Part 8's closing note returns to this).
Two loose ends remain, and both are things every porter eventually bumps into: the small,
fixed pot of RAM you must divide up, and the handful of fatal errors that tell you when you have
divided it wrong or made a mistake in config.vhd.
QNICE has no dynamic memory manager, so the Shell uses a hand-planned layout, and part of that
plan is yours to tune. Recall from Part 1 that variables grow up from 0x8000 and the stack grows
down from near 0xFEE0; between them sits a fixed pot of RAM that three appetites compete for:
- How many files the browser can show at once. The browser builds a sorted list with one entry per file in the current folder, in the general heap. More heap → more files per folder before it runs out.
-
How big your On-Screen-Menu can be. The live menu structure and its dynamic strings (mount
labels, submenu summaries) live in the menu heap. More menu items and more
%sstrings need more of it. - How deep you can nest folders. Each level the browser descends consumes the browser stack.
You size these with four constants at the bottom of m2m-rom.asm:
| Constant | Feeds appetite | Rule of thumb |
|---|---|---|
HEAP_SIZE |
files in the browser (1) | make it large if your core browses big folders of images |
MENU_HEAP_SIZE |
the OSM menu (2) | grow it as you add menu items and mount/submenu lines |
B_STACK_SIZE |
folder recursion (3) | ≥ 768; grow only for very deep directory trees |
STACK_SIZE |
overall stack (includes the browser stack) | ≥ 768 above B_STACK_SIZE
|
The one iron rule: they all draw from the same fixed pot, so if you enlarge one you must shrink
another by the same amount. Increase MENU_HEAP_SIZE by 512 and you must decrease HEAP_SIZE
(or STACK_SIZE) by 512, and update the comments to match — nothing checks this for you, and
getting it wrong is exactly what the fatal errors in §8.3 catch. (One subtlety: a slice of
MENU_HEAP_SIZE is carved off at runtime as the "OPTM heap" that holds the dynamic menu strings;
you do not size it separately — it is simply whatever is left of the menu heap after the menu
structure is copied in.) The full layout, with a worked walk-through of every constant, is the
subject of the Shell memory layout article.
You do not have to guess these sizes. The first time you press Help after the core starts, the Shell prints a memory-utilization report to the serial console (Part 3). It looks like this:
Maximum available QNICE memory: 31994
Used as general heap: 28544
Used as menu heap: 1664
Used as stack: 1536
Used as general stack: 768
Used as browser stack: 768
Free QNICE memory: 250
Free space in QNICE ROM: 5631
OSM heap utilization:
MENU_HEAP_SIZE: 1664
Free menu heap space: 260
OPTM_HEAP_SIZE: 260
Free OPTM heap space: 17
Read the bottom line like a fuel gauge. "Free OPTM heap space" is how much slack your menu has
left; as you add menu items and mount points it drops toward zero. When it gets uncomfortably
small, increase MENU_HEAP_SIZE (and decrease HEAP_SIZE or STACK_SIZE to match — §8.1). The
"Free QNICE memory" line is your overall slack; if it is healthy you have room to shuffle. This
one report answers most sizing questions without any trial and error.
When the Shell hits an unrecoverable problem it calls FATAL, which paints a full-screen error,
prints the same message to the serial console, and quits to the Monitor (Part 3) — so with a JTAG
cable attached you can investigate. The message names the cause, and the important ones point you
straight at the fix. These are the ones you are most likely to trigger, all from mistakes in your
own config.vhd, globals.vhd, or memory sizing:
| The message says | What went wrong | The fix |
|---|---|---|
Illegal menu size (OPTM_SIZE) |
OPTM_SIZE is not between 1 and 254 |
correct OPTM_SIZE in config.vhd
|
No start menu item tag (OPTM_G_START) |
no menu item is flagged as the start item | add OPTM_G_START to one item |
submenu is not having an ending OPTM_G_SUBMENU |
a submenu open/close marker is unpaired | balance every submenu's markers |
Each line in OPTM_ITEMS needs ... a newline |
a menu line is not \n-terminated |
terminate every OPTM_ITEMS line |
More menu items have ... OPTM_G_MOUNT_DRV than ... C_VDNUM |
more mount-drive items than virtual drives | match the counts in config.vhd and globals.vhd
|
Heap corruption: Hint: MENU_HEAP_SIZE |
the menu no longer fits the menu heap | raise MENU_HEAP_SIZE (shrink HEAP_SIZE) |
Heap corruption: Hint: OPTM_HEAP_SIZE |
the dynamic menu strings overflow the leftover menu heap | raise MENU_HEAP_SIZE
|
Stack overflow: Hint: B_STACK_SIZE |
folder recursion went deeper than the browser stack | raise B_STACK_SIZE
|
Notice the shape of these: almost every one is a config.vhd or globals.vhd mismatch, and the
message tells you the exact constant to change. The most common by far is the pair of "Heap
corruption" errors, which are simply the memory budget of §8.1 caught in the act of overflowing —
grow the menu heap, shrink the file heap, keep the total constant. (There is also a generic
Instable system state fatal that carries a small numeric code; each code maps to a specific spot
in the source, so if you ever see one, note the number and grep for it.) A useful discipline: keep
a serial terminal attached during development, because the fatal message and the memory report
both go there, and both turn a mysterious hang into a one-line diagnosis.
It is worth ending where Part 7 pointed. The most advanced things in M2M were not designed into
the framework up front — they were built inside a real core, as core-side firmware driving the
Shell's internals, and then, once proven on hardware, promoted into M2M/ for everyone. The
HANDLE_CORE_IO heartbeat that powers the Amiga's writable floppy arrived exactly this way; the
C64's cartridge and REU work travelled the same road before it. When you learn to read and write this firmware,
you are not just configuring a menu — you are joining the loop by which the framework itself
improves. Your core's clever hack is tomorrow's framework feature.
-
The exact porting steps: The Ultimate MiSTer2MEGA65 Porting Guide is your companion for
actually doing the work this article explains — filling in
config.vhd, wiringglobals.vhd, and turning the callbacks above into a working core. - The menu you are programming: config.vhd Switches and Settings and Welcome and Help Screens cover the data side of the OSM that the callbacks in Part 5 react to, and Shell memory layout is the deep dive on the memory budget of Part 8.
- The hardware the porthole reaches: Devices enumerates the framework and core devices behind the 4 KW window from Part 1, and HyperRAM for Beginners plus HyperRAM FIFO and Caching Strategies cover the 8 MB of memory that the Amiga write-back of Part 7 streams through.
-
QNICE itself, in full: the project home at https://qnice-fpga.com and the source at
https://github.com/sy2002/QNICE-FPGA. For the assembly language, the one-page "programming
card" and the
qnice_introdocument underM2M/QNICE/doc/are the authoritative references behind Part 2, and the Monitor's library is documented inM2M/QNICE/doc/monitor/doc.pdf.