Skip to content

QNICE and the Shell ROM (m2m rom.asm)

sy2002 edited this page Jul 14, 2026 · 4 revisions

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.


Part 1 — QNICE: the little CPU that runs the show

1.1 Why there is a whole CPU inside your FPGA

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.

1.2 What QNICE actually is

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.

1.3 Everything is memory: the address space

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
0x00000x6FFF ROM the firmware program — the Shell (m2m-rom.rom)
0x80000xFEFF RAM variables, the heap, and the stack
0xFF000xFFFF I/O page the top 256 words: memory-mapped device registers

(The range 0x70000x7FFF 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.

1.4 The porthole: how QNICE reaches the whole FPGA

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 (at 0xFFF4) selects the device — which piece of the FPGA world.
  • M2M$RAMROM_4KWIN (at 0xFFF5) 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 0x0100 are reserved by the framework. Device IDs 0x0100 and above belong to your core. When you add your own RAM, ROM, or register block to a core, it answers at a device ID of 0x0100 or higher; anything lower is M2M's (video RAM, the config ROM, the scaler, HyperRAM at 0x0004, 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.

1.5 The QNICE that M2M ships — and what it leaves out

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.


Part 2 — The QNICE assembly language

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.

2.1 Sixteen registers, and the register-bank trick

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:

  • R15 is the program counter (PC) — the address of the instruction being executed.
  • R14 is the status register (SR) — the flags (§2.4), plus the register-bank selector.
  • R13 is the stack pointer (SP) — the top of the stack.

That leaves R0R12 as general-purpose registers. But they are not all alike, and this is the one genuinely unusual thing about QNICE:

R8R12 are global. R0R7 are a window onto a much larger bank of registers.

Think of R0R7 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:

  • INCRBincrement the register bank: give me the next fresh set of R0R7.
  • DECRBdecrement 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. R8R12 are global on purpose: they are how a caller passes arguments into a subroutine and gets results back out, since rotating the R0R7 turntable leaves them alone. More on that in §2.5.

2.2 Moving data: the four addressing modes

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.

2.3 The instruction set at a glance

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.

2.4 Making decisions: the status register and conditional branches

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

2.5 Subroutines and the calling convention

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 R8R12. The caller loads R8 (and R9, 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's INCRB.
  • The routine gets private scratch via INCRB/DECRB. It works in R0R7, confident it is not clobbering the caller's R0R7.
  • A boolean result is often returned in the carry flag instead of a register, so the caller can RBRA ..., C right after the call with no compare. Many Shell helpers (like M2M$CHK_EXT, "does this filename end in this extension?") answer this way.
  • RET returns. It is not a dedicated instruction — it is the macro MOVE @R13++, R15, i.e. "pop the saved return address off the stack into the program counter." (Symmetrically, RSUB pushed 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, R8R12 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.

2.6 The assembler, and the traps that waste an afternoon

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.


Part 3 — The Monitor and the development loop

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.

3.1 The Monitor: QNICE's operating system

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:

  1. 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 those SYSCALLs resolve. Think of this face as the machine's BIOS: low-level services other code calls.
  2. 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.
  3. 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.

3.2 Talking to the Monitor: the interactive console

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.

3.3 The two-switch development model

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 RELEASE defined, the firmware is assembled as a self-contained ROM image: it starts at address 0x0000, includes the whole Monitor itself (so the SYSCALLs work), and auto-starts at START_FIRMWARE, with variables placed in RAM from 0x8000 up. This is the image you flash for a release.
  • With RELEASE undefined, the firmware is assembled to run in RAM: it starts at 0x8000 and expects the Monitor to already be present in ROM. This is a plain program you can M L into 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&gt; 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&gt; C R 8000<br/>run and test on real hardware"]
    D --> A
Loading

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.

3.4 Dropping into the Monitor from a running Shell

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 C your way around memory-mapped registers — poke M2M$CFM_DATA to flip a menu bit, read a device register, watch a value — and then C R 1C1F to 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.

3.5 The other test loop: the headless emulator

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 0x8000 is unambiguous; in the interactive shell, RUN 8000 would start at 0x1F40. When in the interactive shell, write RUN 0x8000.
  • The interactive RUN does not set the stack pointer (batch mode does, to VAR$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 (timeout on Linux; on macOS, a Python subprocess(..., 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.


Part 4 — m2m-rom.asm, dissected

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.

4.1 The shape of the file

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.

4.2 From power-on to a running core

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.

4.3 The main loop that never sleeps

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.


Part 5 — The callbacks: where your core meets the Shell

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.

5.1 SUBMENU_SUMMARY — a living headline for a submenu

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.

5.2 FILTER_FILES and CUSTOM_MSG — shaping the file browser

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.

5.3 PREP_LOAD_IMAGE — validating and typing an image

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.

5.4 PREP_START — the last quiet moment before the core runs

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.

5.5 OSM_SEL_PRE and OSM_SEL_POST — reacting to menu choices

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_PRE runs before the framework applies your selection to the hardware (before it copies the new menu bit into M2M$CFM_DATA).
  • OSM_SEL_POST runs 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.

5.6 HANDLE_CORE_IO — a slice of time on every pass

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_DEV and 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.


Part 6 — The toolbox

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.

6.1 Two libraries, two ways to call

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, like SYSCALL(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, like RSUB 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 R8R12, results in R8R12 or the carry flag.

6.2 The dozen you will actually use

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. SET writes only the in-memory menu mirror; FORCE also 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 of FILTER_FILES.
  • M2M$RPL_S — splice a string into the %s of another (with an ellipsis if it overflows the width). The heart of SUBMENU_SUMMARY and custom messages.
  • SAVE_DEVSEL / RESTORE_DEVSEL — save and restore the porthole selection (M2M$RAMROM_DEV and 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 an M2M$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).

6.3 The QNICE Monitor standard library

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,R9R10 = sign
String SYSCALL(strcpy, 1) copy a string R8 = src, R9 = dst
String SYSCALL(strstr, 1) find a substring R8,R9R10 = 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,R9R11:R10
Math SYSCALL(divu, 1) (MTH$DIVU) unsigned divide R8,R9R10 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 R8R12 (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.

6.4 The M2M Shell library

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.

6.5 Three naming traps

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_, not OPTM$ — underscore, no $. There is no OPTM$GET_SETTING; the routine you want for reading a menu item is M2M$GET_SETTING in tools.asm.
  • M2M$LOAD_POLYPHASE is 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.asm rather than the stock QNICE one, so that SYSCALL(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.


Part 7 — Advanced: driving Shell internals for remarkable results

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.

7.1 The challenge: a floppy that survives power-off

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.

7.2 The strategy: reject the mechanism, keep the pattern

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.

7.3 The seam: the HANDLE_CORE_IO heartbeat

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.

7.4 The keystone: snapshot the file handle

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.

7.5 The engine: cooperative, chunked write-back

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.

7.6 The three storage calls — and why dev-V1.61 made this possible

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. In dev-V1.61 this 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.

7.7 Guards and good manners

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 is SAVE_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.

7.8 The transferable recipe

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:

  1. 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.
  2. Snapshot, don't borrow — clone any framework resource (a file handle, a device selection) that the Shell might reuse out from under you.
  3. Chunk cooperatively — do a tiny, bounded slice of work per call and yield; never block.
  4. 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.
  5. 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.
  6. 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).


Part 8 — The memory budget, and the fatal errors you will meet

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.

8.1 The memory you own

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:

  1. 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.
  2. 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 %s strings need more of it.
  3. 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.

8.2 The heap report: press Help and read the serial console

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.

8.3 Fatal errors

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.

8.4 A closing thought: this is how the framework grows

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.


Where to go next

Clone this wiki locally