Calypsi C, part 3: Making large programs

Dan's MEGA65 Digest

Calypsi C, part 3: Making large programs. Dan’s MEGA65 Digest for August 2026.

Calypsi C, part 3: Making large programs.
A leisurely stroll through a dungeon
A leisurely stroll through a dungeon.

Previously, on The Digest… we were looking at ways to fit the original Unix Rogue dungeon crawler game into the MEGA65 memory system using the Calypsi C compiler and a boot loader. The memory map we came up with keeps the KERNAL in memory, so the program can use a KERNAL-based implementation of the C standard library without fuss, in case we needed it. That meant storing both code and data in banks 0 and 1, and using the 45GS02 MAP register to point 24 KB of the 16-bit address space at one of five regions of the 28-bit address space. We got as far as teaching Calypsi how to place code and data into these regions upon request, compiled as if visible at the 16-bit addresses, as well as how to output each region as a separate file that could be loaded into memory by a boot loader program.

The 24 KB window works like a giant spotlight pointed at one region at a time. We need a mechanism to move that spotlight when code in one region wants to access code or data in another region. Calypsi doesn’t know how to do this when generating code for function calls, so we also need to revise the Rogue code to move the spotlight at appropriate times. Finally, we need to decide how to organize all of the pieces of Rogue into the regions.

I’ve been working with Calypsi’s author hth313 while writing this series, and he has a new version with improvements based on those discussions. Go get Calypsi 5.18 for your platform. A bit of code in this Digest depends on a fix in the most recent version.

Let’s see how far we get!

Paused shipping to EU

Trenz Electronic has temporarily paused shipments of the MEGA65 to the EU except for Austria and France. A new EU regulation on packaging waste has gone into effect, and many small retailers are pausing shipments while they figure out how to comply.

The European Union Packaging and Packaging Waste Regulation (PPWR) seeks to reduce packaging waste, a serious problem in the EU that needs some kind of solution. It covers all packaging for products made for sale in EU member states, including imports. See the European Commission website, as well as this article for more information.

Summer of Cores

Several alternate core projects have been inspiring each other to make progress this summer. I try to only introduce projects in this space and not report on every incremental improvement, but it’s worth doing a round-up, especially considering the growing interest in the MEGA65 as a multi-core platform. As always, see Boris’s MEGA65 alternate cores website (cores.mega65.org) for up-to-date information.

The Amiga 500 OCS core by sy2002 now has a stable version 1 release, with support for a single virtual disk drive, analog CRT displays, and the ability to disable slow RAM. sy2002 is already working on a version 2, with v2 alpha 8 (Discord link) being the latest test release as of this writing. This test release includes read-only physical floppy disk support with the built-in disk drive, up to three virtual drives, and improved support for some disk copy protection. Testers are reporting good results, including the ability to use X-Copy to copy original physical disks to ADF disk images. Note that the core does not yet have the ability to create ADF images on the SD card, so you’ll have to use a PC tool to make some empty images to play with. Be sure to follow along in the Discord when testing alpha releases, and provide feedback.

MJoergen and sy2002 are making improvements to the C64 core with two exciting projects: physical internal 1581 floppy drive support, and a built-in RR-Net-compatible network interface based on the MEGA65’s ethernet port. You can test out version alpha 19-X2 (Discord link) to play along. See Sy’s RR-Net article (Discord link to a PDF attachment) for more information.

sy2002 has also updated the Game Boy Color core to v1.0 (Filehost link) to support the latest R6 mainboards and use the latest MiSTer2MEGA framework. The Game Boy Color core was a very early alternate core, and it’s exciting to see this update bring it forward for the latest hardware.

muse5150 is back with an exciting new vintage desktop computer core. Mega //e is an Apple IIe core, with support for .nib disk images, Commodore-style joysticks and Amiga mice, and various video display options. Caution: do not connect an Apple II mouse to your MEGA65, they are not electronically compatible. See also the Mega //e Github repo. Oh, and muse is also working on an Atari 800 core (Discord, Github).

Fenix has launched a Sinclair QL core (Github link). The QL was based on the Motorola 68008 CPU at 7.5 MHz, with a multitasking operating system called QDOS. This version 1.0 release supports .mdv disk images, selectable memory configurations and CPU speed.

Last but not least, Paolo Pisati (piso) has started an MSX1 core (Discord link), based on the MiSTer core. See the Github repo. The current test version supports PAL video, cartridge ROMs, and Commodore joysticks.

Keep up the good work, everyone!

VCF Midwest

A quick reminder that I’ll be at the Vintage Computer Festival Midwest in Schaumburg, Illinois, USA, September 12-13. It’s one of the biggest volunteer-run vintage computer shows in the United States. Do stop by if you have a chance.

VCF West was a blast. I shared a table area with Garry Kitchen and David Crane, and I got to chat with them about GameMaker, Activision, and 6502 assembly language. Steve Wozniak was on stage telling Apple I stories. I showed off Roguecraft and the Amiga core to tons of people. And I brought home too many books, as always. Good times!

Common memory maps

I know the last few Digest feature articles have felt like a lot of material that seems specific to Rogue and Calypsi, so I want to take a moment to review why this is important. These are generally useful design patterns for large MEGA65 programs.

The 32 KB PRG. The DLOAD command can load a PRG up to 53 KB in size, into bank 0 starting at address $2001. This is sufficient for BASIC programs up to 53 KB. For PRGs that contain a BASIC launcher and machine code (assembly language, compiled C code), the default memory map invoked by the SYS command only allows the CPU to see 40 KB of this as machine code. The default Calypsi linker config limits this to 32 KB, which can be amended to use the remaining 8 KB for the heap and soft-stack if needed. The KERNAL remains fully available to the program in this MAP.

Short programs and far data. Calypsi uses 16-bit addressing for code, heap, stack, and variables not flagged as “far” data (the __far qualifier). A program that keeps all of its code in the first 32 KB could use upper memory for data, in several ways. One way is to amend the linker config with “far” sections, and tell Calypsi to put specific variables there with the __far qualifier. Another way is for the program to manage upper memory directly, using __far pointers and addresses. A third way—which we haven’t properly introduced yet in the Digest and I’ve been meaning to get to it—is to use the DMA feature of the CPU to copy upper memory into lower memory as needed. The program might not even need to manipulate this memory much if it’s just loading in graphics or sound data, to be pointed at by hardware registers. Such a program can still be a single PRG file, and can still use the KERNAL at any time.

Multi-region programs and boot loaders. When machine code outgrows the 32 KB, the next step up is the strategy we discussed previously. KERNAL code stays visible in the upper half of the 16-bit address space (MAPHI), KERNAL variables stay visible in the lower half (MAPLO), and DOS variables remain unmolested in bank 1. There’s room for the heap and soft-stack in the upper half of bank 0. That leaves a 24 KB region of the lower half, $2000-$7FFF, that can be pointed to any region in memory using the MAP register. This requires manually organizing the program into 24 KB regions, and using a technique for transitioning code execution between regions that we’ll discuss in this Digest. It also requires a separate bootstrap program that the user DLOADs and RUNs to pull all of the regions into memory from separate files, and launch the program.

KERNAL-less full control. If a program doesn’t need the KERNAL, or only needs it under specific circumstances, the program can take over the entire 16-bit address space and do whatever it wants. It still has to comply with a few requirements of the CPU, specifically the placement of the base page (“zero page”) and CPU stack, and the management of interrupt handlers, which the KERNAL does for us in the other schemes. The program could even switch back to a KERNAL-compatible MAP temporarily to use its features, such as accessing disks, as long as it’s careful to stash and restore KERNAL/DOS variable space and meet other pre-conditions. More control over 16-bit address space gives the program more options for efficiency and speed, with less time spent on MAP switching and far data access.

Rogue could be a KERNAL-optional program. It only needs disk access when saving and restoring the game, or updating the high score table. But I don’t want to make the first version too complicated—unless I have to.

Dispatching function calls between regions

We’ve seen how to convince Calypsi to put code and data into different regions, using the #pragma statement. But all this does is change how the output of the compiler is organized. The code that Calypsi generates for calling a function simply uses the 16-bit address assigned to the fragment in the region. If a function in one region calls a function in another region, we need to add code to the program that changes the MAP before the call, and changes it back afterward.

Consider a simple C function, and another function that calls it:

int times(int a, int b) {
  return a * b;
}

void main() {
  int result = times(7, 3);
}

If we compiled this program in Calypsi… well, it’ll outsmart us and generate machine code for a single function that stores the number 21, pre-calculated by the optimizer. For this thought experiment, imagine that optimization is turned off, or that this otherwise doesn’t happen.

When the compiler sees times(7, 3) in the definition for main(), it produces code that performs these steps:

  1. Prepare the arguments 7 and 3.
  2. jsr to the address of the times subroutine. The subroutine executes, prepares the return value, then executes an rts instruction.
  3. Manage the value returned by times.

One of the biggest tasks of the compiler is to figure out the optimal code to perform these steps, in a way that works for all callers of the times function. In most cases, the compiler puts arguments in the zero page, then adds instructions that move them around to meet the expectations of the function being called. If necessary, it’ll put arguments on the C soft stack. The code that it generates for the times subroutine knows where to find its arguments, and all callers comply with the decisions that the compiler has made.

The address of the times subroutine is determined by the linker. The compiler generates object code that leaves the address of the jsr instruction blank, to be filled in later. In this example, the compiler assigns both functions to the same section, and the linker puts them next to each other in memory at distinct 16-bit addresses.

Now imagine that we’re using our regional memory system, and the functions are in two separate regions.

#pragma clang section text = "r2Text" data = "r2Data" rodata = \
    "r2Rodata" bss = "r2Bss"

int times(int a, int b) {
  return a * b;
}

#pragma clang section text = "r0Text" data = "r0Data" rodata = \
    "r0Rodata" bss = "r0Bss"

void main() {
  int result = times(7, 3);
}

The #pragma clang section directives tell the compiler to generate the object code for each function earmarked for different sections. When the linker goes to place the fragment the compiler generated for the times function, it consults our mega65-crogue.scm file and sees that the r2Text section starts at address $2000, and so wires up the times subroutine to be at that address. When the linker places the main function, it sees the compiler has requested the r0Text section, which is also configured to start at address $2000. So the linker wires up main as if it lives at $2000.

Obviously, we wanted this to happen. These subroutines will actually reside in different regions of memory, and we want to add a step to move the MAPLO window from region 0 to region 2 when main calls times. Perhaps the calling code looks something like this:

  1. Prepare the arguments 7 and 3.
  2. Update MAP to switch to region 2. (Uh oh…)
  3. (?) jsr to the address of the times subroutine. The subroutine executes, prepares the return value, then executes an rts instruction.
  4. (?) Update MAP to switch to region 0.
  5. (?) Manage the value returned by times.

Without further changes, the final machine code for main includes the instruction jsr $2000, which is the 16-bit address of the times subroutine in region 2. We want this to occur after we change the MAP, so that times is visible at address $2000. There’s just one problem: the jsr $2000 instruction is in region 0. As soon as we change the MAP, region 0 will no longer be visible, and the CPU won’t see the jsr instruction.

For all intents and purposes, you can’t perform a MAP change with instructions in the 16-bit addresses whose MAP is changing. When the CPU performs the map instruction, the program counter advances to the 16-bit address for the next instruction, but the view of memory has changed out from underneath it.

The solution is to use a dedicated subroutine for managing the transition, and to put that subroutine outside the regional memory window. In my mega65-crogue.scm file, I reserve a “common” section at addresses $1600-$1FFF for this purpose. The revised function call should look something like this:

  1. Prepare the arguments 7 and 3.
  2. Call the dispatch routine in the “common” section, giving it the new region (2) and the subroutine address for times ($2000). The dispatch routine performs these steps:
  3. Change the MAP to region 2.
  4. jsr to the address of the times subroutine. The subroutine executes, prepares the return value, then executes an rts instruction to return to the dispatch routine.
  5. Restore MAP to region 0.
  6. Perform an rts instruction to return to the caller.
  7. Manage the value returned by times.

The jsr to the times routine functions correctly because it occurs in the “common” section, unaffected by the MAP change. When times returns, control returns to the dispatch routine, so it can change back to region 0 before returning to the main subroutine.

Mixing assembly language and C

Recall that to update the MAP register, you perform the following 45GS02 machine code instructions:

  ; MAPLO = $(E)0 $A0
  ;         X     Accumulator
  ;   Map    $E = %1110 = $2000-$7FFF
  ;   Offset $080(00)  
  lda #$80
  ldx #$e0  

  ; MAPHI = $(8)3 $00
  ;         Z     Y
  ;   Map    $8 = %1000 = $E000-$FFFF
  ;   Offset $300(00) 
  ldy #$00
  ldz #$83

  map  ; MAP change occurs here. Disable interrupts.
  eom  ; End of MAP change. Re-enable interrupts.

This example sets MAPLO so our region window $2000-$7FFF is pointing at region 1, which starts at 28-bit address $0.A000. This uses an offset of $0.8000, so 16-bit address $2000 is 28-bit address $2000 + $0.8000 = $0.A000.

Similarly, this example sets MAPHI so 16-bit addresses $E000-$FFFF see KERNAL code at $3.E000-$3.FFFF, using an offset of $3.0000. You must always set both MAPLO and MAPHI when invoking the map instruction, because it’ll use all four A, X, Y, and Z CPU registers. In Rogue’s case, I’ll just always set Y and Z to $00 and $83 when changing MAP.

So how do we get this assembly language code into our C program? There are two ways to do this: assembly language files, and inline assembly language inside C functions.

Calypsi has its own assembler, which can be invoked with the command as6502. You can build entire assembly language programs with Calypsi, but it is more appropriate to use it to mix assembly language and C in the same project. Given an assembly language source file (typically with the .s filename suffix), as6502 can produce an object file that can be linked with other object files into the final program. As with the C compiler, the object file the assembler produces is not a complete program, and needs the linker to decide where the machine code lives in memory.

The Calypsi MEGA65 starter project has a Makefile rule that assembles all .s files in the project into object files. I’m using that Makefile, so I merely need to create .s files alongside .c files, and they will be built into my project.

Assembly language source code for a C project looks a bit different from a pure assembly language project in other assemblers. The code needs to tell the linker the intended section for the code, and how to make a subroutine available to C code as if it were a function. With some effort, an assembly language routine can be a full C function that other C code can call with arguments, and can return a value. The routine can even access other C functions and global variables.

Here’s the beginning of a file named dispatch.s in my code, which sets up region_dispatch as an external symbol that the linker can use as an address for the dispatch code:

  .public region_dispatch

  .section commonText, text, root

region_dispatch:

  ; ...
  
  rts

I also have a header file dispatch.h that describes how the C code should treat this subroutine as a function. I’ll explain this more later, but for now it can just contain this declaration:

void region_dispatch();

This is enough for the C program to invoke the region_dispatch assembly language subroutine by calling the region_dispatch() function, without arguments or a return value. The .section directive assigns it to the commonText section, type text (code), which my linker configuration puts in $1600-$1FFF. This code will always be resident, so any code can call it at any time, regardless of the current MAPLO window setting.

Assembly language can refer to symbols in other modules, such as global variables or C functions, by declaring them as “externs,” like so:

  .extern monster_table

  ; Load the 16th byte from the monster_table memory.
  ldx #$10
  lda monster_table,x

If you know that the symbol refers to a zero page address, prefix it with zp: to force zero page addressing:

  .extern foo
  
  lda zp:foo

If the symbol refers to a 16-bit value, prefix it with .byte0 or .byte1 to access its individual bytes:

  .extern some_addr, ptr
  
  lda #.byte0 some_addr
  sta zp:ptr
  lda #.byte1 some_addr
  sta zp:ptr+1

Assembly language knows nothing about C structures or types. Take care that the assembly code is treating the memory the way it wants to be treated.

For more on Calypsi assembler syntax, see the Calypsi guide, chapter 21: Assembler. For information on passing arguments to or returning values from assembly language functions, see chapter 20: Assembly language interface.

Inline assembly language

If you just need a few raw CPU instructions inside a C function and don’t need them to be separate functions, you can use inline assembly. You invoke the __asm() directive directly within a C function, and give it a C string containing Calypsi assembly language. This machine code is inserted into the definition of the function.

void foo() {
  // ...

  __asm(
    " inc 0xd020\n"
    " inc 0xd021\n"
  );

  // ...
}

The C string must resemble an assembly language source file, including a leading space and a newline for each line, as shown.

The __asm() directive takes three optional “constraints,” separated by colons. The second and third of these can be a list, delimited by commas. These constraints serve the following purposes:

  • Output variable. The assembly code prepares a result to return to the C code. It does so via a given register, which populates a given C variable.
  • Input expressions. The C code prepares one or more values into registers prior to executing the assembly code.
  • Clobbered registers. The assembly code modifies the given registers. Calypsi will take this into account when optimizing the code that surrounds the assembly code.

Here’s the example from the Calypsi guide:

char foo(char xx) {
  char out;
  __asm(
    " inx\n"
    " inx\n"
    " inx\n"
    " inx\n"
    " txa\n"
    : "=Ka" (out)
    : "Kx" (xx)
    : "a", "x"
    );
  return out;
}

This says:

  • "=Ka" (out) : After executing this inline assembly code, take the accumulator (a) and store it in the local byte variable out.
  • "Kx" (xx) : Prior to executing this inline assembly code, prepare the X CPU register (x) with the value of the local byte variable xx.
  • "a", "x" : This inline assembly code modifies the accumulator and X CPU registers.

(The “=” and “K” symbols are required as shown.)

If you don’t need a constraint, omit it, but keep its colon. For example, to indicate clobbered CPU registers without output or input constraints:

  __asm(
    " inx\n"
    " txa\n"
    ::: "a", "x");

Calypsi supports register classes for a, x, y, z, and q for the 45GS02 CPU.

You can also request zero page soft registers for the output and inputs, using the desired size as the register class: zp8, zp16, or zp32. The assembly code refers to a zero page output variable address using %0, and refers to zero page input variables using %1, %2, etc. in the order they appear in the input expression list. You can also give these inputs names instead of numbers; see the Calypsi guide for the syntax.

For more on inline assembly, see the Calypsi guide, chapter 20: Assembly language interface, section 20.4: Inline assembler.

Implementing the dispatch routine

With assembly language at my disposal, I can now implement the dispatch mechanism. This needs to be a little bit clever.

I mentioned that Calypsi generates code for a function call to prepare arguments, perform the jsr, then process the return value. Ideally, for a dispatched function call, the MAP changes happen just before and after the jsr, so Calypsi can still own the argument and return value prep. If the C code says int result = times(7, 3);, there isn’t really a way for me to inject the MAP changes into Calypsi’s usual sequence. Whatever alternative I provide needs to work for any function type signature. Also, the dispatch routine needs input parameters of its own, including the destination region.

Here’s what I came up with:

  1. Use global variables to store the destination MAPLO setting and function address.
  2. Cast the function pointer to (void (*)(void)) to support all possible function signatures.
  3. Cast the dispatch function to ((__typeof__(&(func))), such that Calypsi treats the dispatch function as if it had the signature of the called function.
  4. Call the dispatch function with the called function’s arguments.
  5. Within the dispatch function, detect the current MAP setting, and push it to the stack so it can be restored later.

These type casting gymnastics took me a while to figure out. In particular, __typeof__ is a non-standard but popular extension of the C99 standard. It was added to GNU C a while ago, and only adopted into the C standard starting with C23. (The standard numbers refer to the year they were ratified: C99 in the year 1999, C23 in 2023.) Calypsi supports __typeof__.

In dispatch.h:

void region_dispatch();
extern volatile uint16_t region_dispatch_dest_maplo;
extern void (*region_dispatch_dest_addr)(void);

A dispatched call to times(7, 3) might look like this:

  region_dispatch_dest_maplo = 0xe100;
  region_dispatch_dest_addr = (void (*)(void))times;
  ((__typeof__(&(times)))region_dispatch)(7, 3);

There’s no way I’m typing all of that out for every dispatched call. That’s what preprocessor macros are for. Back in dispatch.h:

#define REGION_0 0xe000
#define REGION_1 0xe080
#define REGION_2 0xe100
#define REGION_3 0xe160
#define REGION_4 0xe1c0

#define REGION_DISPATCH(dest_maplo, func, ...)           \
  ({                                                     \
    region_dispatch_dest_maplo = dest_maplo;             \
    region_dispatch_dest_addr = (void (*)(void))func;    \
    ((__typeof__(&(func)))region_dispatch)(__VA_ARGS__); \
  })

In the earlier example, we put a function called times() in region 2, and want to call it from the main() function in region 0.

Instead of:

void main() {
  int result = times(7, 3);
}

I can now dispatch between regions like so:

void main() {
  int result = REGION_DISPATCH(REGION_2, times, 7, 3);
}

The C preprocessor performs transformations on a C source file before handing it over to the compiler. For example, when it sees a #include directive, it replaces it with the contents of the file being included. #define can be used to define names that get replaced with strings. In this example, REGION_2 gets replaced with 0xe100.

You can also define macros that take arguments. These look kind of like functions, but they aren’t, it’s just more text substitution. In this example, after REGION_2 is replaced with 0xe100, the REGION_DISPATCH macro expands to its definition with dest_maplo replaced with 0xe000.

As of C99, macros support variadic argument lists. The ellipsis (...) at the end of the argument list says that the macro can accept any number of comma-delimited arguments at that point. The special symbol __VA_ARGS__ in the macro definition expands to all of these arguments, preserving the commas.

According to the C standard, C99 requires at least one variadic argument. C23 added support for empty variadic argument lists. Calpysi has not back-ported this feature exactly, but it seems to support empty variadic argument lists anyway with the C99 syntax, so I’m not complaining.

The slashes at the ends of the macro definition lines are important. Preprocessor macros are required to be on a single effective line of the source file. Each slash says the macro continues on the next line. It’s important to remember that this isn’t C source code, this is a C preprocessor directive that becomes C source code later.

The macro expands to multiple lines of code, inside parentheses and brackets: ({ ... }) This is a statement expression, and is critical for this technique to work. A statement expression contains multiple C statements, then ends with an expression. The entire expression evaluates to that last expression. This causes the entire macro expansion to evaluate to the return value of the dispatch function, so the macro can be used just as a function with a return value might be used. This also works for void-type expressions, without changes to this example. Statement expressions are another GNU C extension not strictly supported by the C standard, but they are supported in Calypsi.

Here is a complete implementation, in dispatch.s:

  .public region_dispatch_dest_maplo
  .public region_dispatch_dest_addr
  .public region_dispatch

  .section commonData, data, root

region_dispatch_dest_maplo:
  .word 0x0000
region_dispatch_dest_addr:
  .word 0x0000
region_dispatch_last_maplo:
  .word 0x0000
register_stash:
  .long 0
region_dispatch_stack_i:
  .byte 0
region_dispatch_stack:
  .space 32,0

  .section commonText, text, root

region_dispatch:
  ; Stash A, X, Y, Z
  stq register_stash

  ; Stash the current MAPLO setting on a stack.
  ldy region_dispatch_stack_i
  lda region_dispatch_last_maplo + 1
  sta region_dispatch_stack,y
  iny
  lda region_dispatch_last_maplo
  sta region_dispatch_stack,y
  iny
  sty region_dispatch_stack_i

  ; Remember the new MAPLO setting, and set MAP.
  ldx region_dispatch_dest_maplo + 1
  stx region_dispatch_last_maplo + 1
  lda region_dispatch_dest_maplo
  sta region_dispatch_last_maplo
  ldy #0x00
  ldz #0x00
  map    ; The function's region becomes visible here.
  eom

  ; Restore A, X, Y, Z.  
  ldq register_stash

  ; Call the function.
  jsr (region_dispatch_dest_addr)
  
  ; Stash A, X, Y, Z, as returned by the function.
  stq register_stash

  ; Pull the caller's MAPLO off the stack, and restore it.
  ldy region_dispatch_stack_i
  dey
  lda region_dispatch_stack,y
  sta region_dispatch_last_maplo
  dey
  lda region_dispatch_stack,y
  sta region_dispatch_last_maplo + 1
  sty region_dispatch_stack_i
  
  tax
  lda region_dispatch_last_maplo
  ldy #0x00
  ldz #0x00
  map    ; The caller's region becomes visible here.
  eom

  ; Restore A, X, Y, Z, as returned by the function.
  ldq register_stash
  
  rts

The region_dispatch assembly language routine is standing in for the dispatched function. We are tricking Calypsi into thinking it is the dispatched function so that it prepares arguments and manages return values in the same way as simply calling the function. The first thing region_dispatch does is stash the CPU registers to be restored just before calling the dispatched function, in case they’re important. They may not be, but we don’t want to confuse Calypsi.

The dispatch subroutine keeps track of the current state of MAPLO by storing it in a variable every time it is set (region_dispatch_last_maplo). This works fine as long as dispatch is in full control of the MAP register, which it is in this case. There is a way to read the current state of the MAP register by triggering the MEGA65’s Hypervisor, but this is not needed in this case.

Before it calls the function, the dispatch subroutine remembers the caller’s MAPLO using a stack data structure. When the function returns, it pulls the caller’s MAPLO off of the stack and restores it, so it can return control to the caller. The stack allows for the function being called to make its own dispatch calls: if the function being called tries to dispatch somewhere else, the callers pile up in the stack, then un-pile as they return. I allocated 32 bytes for this stack, so this mechanism supports 16 “nested” dispatch calls.

Disabling Calypsi code reuse

There’s one more subtle thing we need to get this technique to work. On its strongest setting, Calypsi’s optimizer will attempt to share pockets of generated assembly code between functions. This is clever and useful, but we have to tell Calypsi to not share code between two functions that live in different regions.

Add these two arguments to the compiler (cc6502) command line, in your Makefile: --no-cross-call and --no-interprocedural-cross-jump

cc6502 --target=mega65 -O2 ... --no-cross-call --no-interprocedural-cross-jump ...

I didn’t know about this one at first and spent days trying to figure out why my program was crashing. I stepped through the generated assembly language, noticed the cross-call, and thought I was sunk. Then kibo told me about these command line arguments, and the day was saved. Thank you kibo!

Limitations of the dispatch technique

The original proposed memory map for Rogue, with KERNAL always visible
The original proposed memory map for Rogue. (Click to enlarge.)

Last month I proposed that this region switching mechanism (the MAPLO window) could accommodate keeping the KERNAL active in the 16-bit address space, and could still use the RAM underneath the KERNAL’s addresses in bank 0 as a mappable region. I described a candidate memory map that did so, with regions all over bank 0 and 1 that avoid colliding with KERNAL and DOS variable space. There’s no reason I couldn’t allocate more regions in banks 4 and 5 if needed, I just wanted to see if this program would fit entirely into banks 0 and 1.

The most important thing to remember about this region switching technique is that the compiler will treat everything as if it is accessible at 16-bit addresses. At any given point in the code, the 16-bit address space must meet this expectation with whatever region is active. This works fine if the code in the active region only needs to access static memory (constants, local variables) within its own region, or on the heap, C stack, or “common” areas, which are always visible.

When code in the active region needs to access code in another region, it uses the dispatch macro to invoke the function. There is no equivalent mechanism to reach directly into another region’s data space. One way around this is to provide accessor functions that could be called via dispatch. Arguments and return values are managed through the dispatch process via the C stack.

Another way around this is to move shared data outside of the region space, such as into the “common” area. There’s not a lot of room in there, but if having just a few shared variables untangles the program, it’s worth doing. You can relocate a module’s data simply by changing the #pragma:

#pragma clang section text = "r5Text" data = "commonData" rodata = \
    "r5Rodata" bss = "commonBss"

More care is needed when passing, storing, and dereferencing pointers. Whatever the pointer points to must be visible when the pointer is dereferenced. The pointer itself can pass through functions in other regions, but the thing that acts on the pointer has to be able to see that data.

This can be counterintuitive when it comes to passing string literals as arguments. C makes it look like the string literal is a value that is being passed to the function like any other. What actually happens is the string literal is generated into the current rodata section, and a pointer to that value is passed to the function. If the string literal is an argument to a dispatched function, the literal will not be visible after the map has changed.

// This won't work.
REGION_DISPATCH(REGION_2, foo, "hello");

One solution for string literals is to tell Calypsi to place all rodata into the “common” area. In my case, I don’t have nearly enough room. Rogue is very string heavy, and makes extensive use of passing string literals as function arguments. Instead, I just barely managed to reorganize functions that take strings into the “common” area, so calls to these functions don’t need the dispatch and the string literals stay visible in the region window.

I eventually decided to implement another dispatch mechanism for functions with a single string argument, which happens frequently in Rogue for printing messages. This new mechanism copies the string argument to a buffer in the “common” area, then calls the function with a pointer to the buffer substituted for the string argument. I won’t paste the whole thing here, but this is what it looks like to use it:

REGION_DISPATCH_STR(REGION_2, "hello", foo, STRARG);

I originally put the default code, cdata, and switch sections in region 0. Calypsi also uses these regions to place the C standard library functions that get pulled in when I use them. I was able to get away with dispatching to standard library functions for a while, but in the end I moved these sections into a common area so I wouldn’t have to worry about it. I also narrowed my dependency on the standard library to just a few features. I didn’t want to make any assumptions about whether the standard library was implemented in a way that was compatible with the dispatch mechanism.

While functional, use of this dispatch mechanism is error prone and difficult to debug. If I use the wrong region ID for either the caller or destination, the program compiles but crashes. If I forget to use dispatch when it is needed, the program compiles but crashes. Debugger breakpoints are fraught, because they’re associated with the program counter and not the 28-bit address of the instruction, so a breakpoint intended for code in one region might trip at the same address in another region.

You might ask, with all of these limitations, is this dispatch technique even a good idea? I have two answers. For one, mostly yes. Most of these issues come from the fact that my project is a port of existing code that was written for the Unix process memory model. If I were writing a program from scratch for the MEGA65, I’d design the program to the constraints of the memory map. And second, you don’t really have much of a choice. If you have more than 32 KB of code, you have to do something about it, eventually needing a mechanism to bring code from upper memory into the 16-bit address space.

A dirty, dirty port

The Rogue function call graph
The Rogue function call graph.

It sure would be nice to end this article series with a complete port of Unix Rogue for the MEGA65 that you could just download and play, even if you’re not interested in the techniques. Unfortunately I don’t have that for you just yet.

Just to be clear: My goal was to port the actual original Unix source code to the MEGA65, not just come up with an arbitrary Rogue-like game that vaguely resembles the original. This is a challenging goal. Rogue relies heavily on the Unix process model to simplify its code base. Any function can call any other function, and any function can access any global data, with all of the overhead of calling functions and accessing data handled by the C compiler. The easiest way to write a game in this environment is to put all of the game state into a single public memory blob, and not think too hard about organizing functions into modules. The developers of Unix Rogue didn’t have to think about 24 KB memory banks or cross-region visibility.

I wanted to get something working as quickly as possible, so I started making some rash decisions. I tried revising my memory map so that the 23,674 bytes of global data were always visible at 16-bit addresses. To do this, I deleted all the disk-based features, disabled interrupts, ejected the KERNAL, and replaced “region 1” with global memory. This was not a complete solution to memory visibility, because many functions still used local variables—and passed the addresses of those local variables to functions in other regions. I plowed through the code, juggled modules between regions, added dispatch calls, and fixed memory visibility bugs as I went. Dispatch calls generate much more machine code than regular function calls, and simply adding dispatch calls caused modules to overflow their regions, requiring more revision. I wrote Python scripts to help manage this process.

Call graph for main.c
Call graph for main.c.

I originally tried to use the call graph to help organize code into regions, thinking that “adjacent” code would benefit from being in the same region and limit the number of dispatch calls. I didn’t get very far with this technique. It was much more important to pack regions tightly. This is a classic partitioning problem, with simple approximate solutions. “Longest processing time-first scheduling” is the algorithm I understand, so I wrote a simple Python script that analyzed module sizes and proposed region placements. The compiler makes .lst files for each C module that summarizes the section contributions in bytes at the end.

Executable        (Text): 4213 bytes
Zero initialized   (BSS): 4880 bytes
Data                    :   16 bytes

The linker’s own crogue-mega65.lst file provides a complete summary of where it puts things and how the sections are filling up, invaluable if I were building up from scratch. In this case, until the regions were packed properly, the linker wouldn’t even finish, printing the relevant information in a giant report of error data.

Because the original code wasn’t written to accommodate regions, I ended up with a grotesque amount of dispatch calls. Nearly every cross-module call became a cross-region call. I allowed this just to get to a point where I could see something running, promising myself I’d clean it up later.

Milestone: a walkable dungeon, with bugs
Milestone: a walkable dungeon, with bugs.

I got far enough to prove the point. The game starts, and you can walk around one floor of the dungeon and pick up objects. I’m confident that the rest of the work is just more of the same, cleaning up memory and code visibility bugs. It successfully runs a 90 KB program spread across two banks of MEGA65 memory, all from compiled C code.

All of this manual effort of placing code into regions and tracking visibility resembles tasks that you would normally delegate to the compiler. If the compiler were aware of the region-based memory map, it could place fragments into regions automatically, optimize their placement to minimize dispatch calls, and generate the dispatch code itself as part of function calls. This could provide a C abstract machine similar to the Unix linear memory space, such that I wouldn’t need to do any of this by hand.

And yes, I tried using genAI coding tools to do this optimization. I won’t go into detail about what I tried and didn’t try, but suffice it to say that inexpensive context windows can’t hold enough information for an LLM to act as a meta-compiler on its own. For my goals of understanding the original Unix Rogue code base and developing the region dispatch system, I didn’t want genAI to completely rewrite the program, so I didn’t make a deeper investment in token costs or genAI workflow. A better approach would be to use genAI to build the meta-compiler—but I’m not inclined to outsource the fun part of my own hobby. 😛

In the end, I’ve decided not to finish the dirty port strategy. I have a way to write large programs in C, and I have bigger Rogue-like fish I want to fry.

What I’m doing instead

I don’t like promising future results for hobby projects—it takes the fun out of it—so don’t take this as a pledge. But I have made some exciting progress on a new path.

Taking what I learned from this dispatch technique, I started a fresh project with a new goal: to implement the maximal memory management strategy. I now have a new C-based game shell with these features:

  1. Potential to use almost all of 384 KB of MEGA65 fast memory for C code, data, CPU stack, and compiler-owned variable, heap, and stack memory.
  2. Full control over hardware interrupts. Handlers are C routines, and can power animations, sound, and music.
  3. Switchable “major” modes. Each major mode has its own memory map, and mode transitions load new code and data regions from disk. Each mode can use all 384 KB of memory, such as a title mode with full-screen murals and menus, and a game mode that repurposes that memory for monsters, potions, treasures, and logic.
  4. A switchable KERNAL-active mode specifically for disk access. KERNAL memory is stashed in Attic RAM when the KERNAL is not active, so game modes can use all available space.
  5. Game state memory that can be saved to and restored from disk.
  6. RRB character graphics baked in.
  7. Unused “ROM” space in banks 2 and 3 repurposed for graphics data.

This shell could be used for all kinds of games and applications. And really, it’s the right way to do a port of Unix Rogue in the first place. Start with a shell like this, then gradually populate regions with Rogue functionality, one piece at a time.

I’m not ready to support this shell as a product. But it’s working, and if I make a full game out of it, I’ll share source code.


It’s been a super busy August, with a combination of retro computer work and family vacation. September will be no different! I have stuff planned for VCF Midwest and not nearly enough time to prepare. I have a more laid-back Digest planned for the end of next month, so hopefully that’ll be a nice change of pace.

Your support for the Digest is more important than ever these days. Thanks as always for the feedback, encouragement, and contributions. If you’d like to support the Digest, please visit: ko-fi.com/dddaaannn

Keep circulating the tapes!

— Dan