An unofficial rewrite of MEGA65/mega65-freezemenu
in modern C for the llvm-mos toolchain. The tools do what the cc65 originals do
in 32-64% fewer bytes for the same work (table below), which leaves
room inside the 34817-byte budget for new work. Most of the C compiles on the host and is tested
there; the rest is driven through the MEGA65 serial link by a test harness,
under Xemu and, down a serial cable, on the machine itself. MONITOR spends
the freed bytes on a syntax-highlighted 45GS02 (dis)assembler; memory map; and a bit editor
that names registers and bits from mega65-core's iomap.txt. The rewrite also
exposed several bugs in the original, listed below. Every push is built under
strict compiler warnings and clang-tidy.
Pressing RESTORE freezes the running program; FREEZER draws the menu and launches the
rest. Each tool is a separate program loaded from the SD card:
| tool | what it does |
|---|---|
FREEZER |
the menu, slot browser and thumbnail |
MONITOR |
memory, disassembler, assembler, bit editor |
MEGAINFO |
system information |
AUDIOMIX |
audio mixer |
SPRITED |
sprite editor |
ROMLOAD |
ROM chooser |
MAKEDISK |
empty disk image |
FILEHOST |
FileHost catalogue browser |
Two kinds of data are read off the card at run time and built here too:
IOMAP.M65, the I/O register names and descriptions, and M65THUMB.M65,
C65THUMB.M65 and C64THUMB.M65, the frames drawn around a slot's thumbnail.
FILEHOST reads a third, CATALOG.M65, which is not built here: it is the
catalogue described below. FILEHOST can also fetch it over the network with
F, into memory rather than onto the card.
FILEHOST has no TLS, so it fetches through a plain-HTTP mirror of
files.mega65.org. A (temporary) public one is set by HTTP-IP.TXT on the SD card.
Press T to set alternative server for the session. You can run your own mirror:
podman build -t m65mirror proxy
podman run -d --name m65mirror -p 80:8080 m65mirror
Should also work with Docker with -f proxy/Containerfile.
Currently needs a patched llvm-mos: stock SDK v23.0.1 miscompiles. You need to build the llvm-mos-sdk using llvm-mos @ freezer and this step is not detailed here. The patches are submitted as PRs to llvm-mos.
cmake -DCMAKE_PREFIX_PATH=<prefix> -B build
cmake --build build
Each .M65 must fit 34817 bytes, which the link enforces.
| variable | what it does |
|---|---|
MEGA65_IOMAP |
path to mega65-core's iomap.txt, to regenerate IOMAP.M65 |
MEGA65_SDIMG |
path to an SD image, for the emulator tests |
MEGA65_ROM |
path to MEGA65.ROM, for the disassembler's real-code test |
MEGA65_CORE |
path to a mega65-core checkout, to check the address resolver against gs4510.vhdl |
XEMU |
path to the emulator, if it is not found automatically |
FREEZER_TRACE |
tracing on the hypervisor serial channel (OFF) |
LTO_ZP |
zero-page bytes the LTO allocator may use (210) |
C_MCPU |
-mcpu for our sources, mos6502 on an unpatched compiler (mos45gs02) |
LINK_LIBC |
link mega65-libc (ON) |
The four paths also read an environment variable of the same name. Set
CPM_SOURCE_CACHE to a directory to avoid re-fetching mega65-libc.
They point at files you already have, rather than anything downloaded:
cmake -B build -DMEGA65_IOMAP=~/github/mega65-core/iomap.txt \
-DMEGA65_ROM=~/MEGA65.ROM
iomap.txt is generated in mega65-core
from the @IO: comments in its VHDL; MEGA65.ROM is the machine's own ROM
image. Neither is ours to redistribute, so neither is included here:
IOMAP.M65 is committed instead, and without a ROM the disassembler's
real-code test does not run.
Other targets: format, tidy, tidy-arithmetic, iomap-names.
ctest --test-dir build
The host tests build portable parts with the host compiler and need
nothing else. The emulator tests need an emulator — found automatically, or
given as XEMU — and an SD image, which defaults to Xemu's own. They clone it
and write the build into the clone's filesystem, so the image itself is never
touched. With mtools installed, one further test checks that writer by having
mdir read back what it wrote.
FREEZER_TRACE is compile-time, so the serial assertions need their own build:
cmake -B build-trace -DFREEZER_TRACE=ON ...
Against the cc65 generated 0.97 binaries we get a 30-65% reduction in byte count for the same functionality. This extra space can be used to add new features.
-
MONITOR:-
45GS02 (dis)assembler
-
Bit editor with named I/O register bits from mega65-core's
iomap(optional SD card file).
-
Pixel view
-
The frozen machine's memory map under
R: which 28-bit range each part of the 16-bit space reached, what is there, and which register bit decided it.
-
Addresses as the frozen program saw them. Bit 31 asks for the CPU's view, so
M 8000E000reads through the frozen map whereM E000is literal -- the convention the MEGA65 monitor documents. -
Fill, compare, copy commands
-
-
Dynamic colour schemes - press P to cycle (Mega65, Amiga Workbench, Gruvbox)
-
FILEHOST: browse the FileHost catalogue over plain HTTP, download and attach D81/prg files.
-
MAKEDISK: the border reports while the card is busy. -
SD traffic can be counted:
-DSDCARD_COUNTERS=ONbuilds three counters that a test reads by name,test/verify_sdcount_xemu.pyreporting what creating a D81 costs. Transactions rather than time -- the emulator has no card -- so the figures are the half that carries to hardware. -
The freeze menu's fixed text is built at compile time and drawn as positioned fragments.
Defects found in original:
- The freeze slot is SD sectors, and
freeze_poke()reads a whole 512-byte sector, changes one byte and writes it back. Storing the mounted image's name and flags a byte at a time therefore cost 68 sector operations for 34 bytes, twice on every boot: 136 where 12 do.freeze_common.c:291-294. - Each branch of the PAL/NTSC toggle wrote the sprite Y adjust twice with the
same value, on both the frozen side and the freezer's own — four more sector
transfers per keypress, for bytes already there.
freezer.c:1118/1125. - The chargen fix read the ROM back off the card. Hyppo loads
MEGA65.ROMto$20000immediately before it loads the freezer (hyppo/task.asm,attempt_loadc65rom), so the charset was already in RAM; the freezer fetched all 131072 bytes again to use the 4096 at offset$D000. Every startup pays it, between the menu text and the thumbnail, and the shipped card carries neither of the smaller files that would have avoided it.freezer.c:826-829. - The disk chooser read the mounted state it saves before previewing an image
from the wrong place entirely.
0x0400 + drive_id ? 0x35 : 0x15parses as(0x0400 + drive_id) ? 0x35 : 0x15, which is the constant$35, so the name came out of zero page -- the compiler's own variables -- rather than the descriptor at$0435. Backing out of the chooser then restored whatever that happened to hold. Two neighbouring reads had the same defect.freeze_diskchooser.c:594-596. - Loading a tall charset wrote to the wrong register.
$D054.5is PALEMU and$D07A.4is CHARY16, but both writes went to$D07A: PALEMU was never touched, and$D07A.5-- NOBUGCOMPAT, deprecated in the core -- was set instead and never cleared again, so it survived into the resumed program.freeze_romload.c:402-405. - Rewriting a sector that would not verify never gave up. The retry loop is
written
while (tries < 10)but nothing incrementstries, so a card that kept failing the read-back was rewritten for ever. The read path next to it counts its attempts correctly.fdisk_hal_mega65.c:236. - A partial sector store checked that the run fitted in a sector but not that
it ended in one, so a run starting late and running long wrote past the
512-byte buffer. The sprite editor is the caller that could: it passes a
runtime address.
frozen_memory.c:237. report_unmapped()called itself. Every unmapped address reached through D, F, H, C, T or B recursed until the stack died. Eight call sites. Every command had been exercised on mapped memory.- Two 16-bit overflows, the second of them the same arithmetic in the ROM loader.
- The thumbnail frame wrote past the end of the screen.
- The disk image writer formatted only half of a D65. It asked the filesystem for 85 tracks of 128 sectors and then cleared 85 of 64, so the back half of every image kept whatever the card had held there.
- Every file it created was dated a day early. The RTC reports the day of the month from one and the FAT directory stores it from one, and the reading subtracted one in between. In twelve-hour mode the same reading made noon 24:00 and midnight 12:00.
- The check for a name already on the disk could never match: the name built out of the directory entry was never terminated, and the trim of the padding began one character past the end of it.
- The FAT chain was written one sector too long, out of a buffer that had not been cleared, so the tail carried the previous sector's bytes.
- Two that need a card with other than 4KB clusters to show: a file's first sector was worked out with a fixed eight sectors per cluster, and the end-of-chain test read $0F000000 where FAT32 ends a chain at $0FFFFFF8.
- A text entry field cleared its first cell once per character instead of clearing the field.
- The thumbnail frame was read back out of memory whether or not it had been
read in. The file is only loaded when the card's current directory is its
root, since hyppo opens by name relative to that directory, but the code that
takes the tile numbers, the screen pointer and the thumbnail's own position
out of
$52000carried no such condition. On a card without the frame files, once the disk chooser had stepped into a subdirectory, all three came from whatever the frozen program had left there -- the thumbnail landing wherever$52020happened to decode to.freezer.c:579. - HOME jumped to slot 0 without redrawing the drive rows, so they went on
showing the previous slot's mounted images until a cursor key was pressed.
The six other slot-change keys pass
UPDATE_DISKand HOME does not, though it does refetch the descriptor those rows are drawn from -- the data was right and only the drawing was skipped.freezer.c:1075. - The freeze-slot copy asked for card feedback with a value nothing tests.
sdcard_visual_feedback(1)sets the flag to 1, and all six sites that poll it while the controller is busy test it for greater than 1, so the border never cycled: the two colours the copy loop sets around each 64KB block were the only sign it was working.freezer.c:1290, againstfdisk_hal_mega65.c:53and five more. - Dead code: a ROM-name update flag, a chunk reader, a pre-blank of the drive numbers, the touch and joystick handling, a copy of the filename into a page the attach writes itself.
ctest runs:
- Host tests build the toolchain-independent code — number formatting, block
moves, the disassembler, the register database, the thumbnail frames, the
disk geometry and FAT records — with the host compiler and check it against
known-good output. The disk numbers are checked against what the machine
requires, taken from
mega65-core: the image sizes hyppo measures to tell a D65 from a D81, and the RTC fields named iniomap.txt. - Emulator tests drive Xemu over its serial monitor, typing at the freezer and
asserting on the screen dump and, with
FREEZER_TRACE, on the hypervisor serial channel. They clone the SD image rather than writing to it. - Hardware tests drive the machine itself down the same monitor protocol, over
a serial cable rather than a socket:
test/m65harness.pyreaches either, and a scenario written against one runs against the other. Holding$52in$D615freezes the machine, which is how a run reaches the freezer with nobody at the keyboard. They register only with-DMEGA65_SERIAL=/dev/cu.usbserial-XXXXXXX, since a run takes the machine over. What earns the trip is the network: Xemu has no ethernet on macOS, soverify_filehost_hw.pyfetching a catalogue over HTTP is the only check the wire ever gets -- and it needs no server stood up first, only a route out. It writes nothing to the card and resumes the frozen program whatever happens.
Warnings are errors (-Wall -Wextra, and the narrowing half of
-Wconversion). clang-tidy runs over every source including
misc-no-recursion. A second, advisory target reports 16-bit overflow and narrowing, int
being 16 bits here.
Generators are Python with no third-party packages like libpng, ophis. Pinned mega65-libc is automatically sources by CMake.
Porting from cc65 to llvm-mos is deceptively simple: getting to a compiled state often requires mechanical changes only,
which however is no guarantee of correctness. Memory placement, ZP, inline asm, volatiles all require attention. The lessons
learned, including how to favor small byte counts, are condensed into a LLM skill
llvm-mos and is automatically activated
by AGENTS.md in the the repo root.
This also loads the mega65-dev
skill which draws information from the MEGA65 User Guide and VHDL core.
Note that skills are merely Markdown files and human readable.
My thoughts on using LLMs are positive and LLM contributions are accepted for this project;
see CONTRIBUTING.md for guidelines and restrictions adapted from the LLVM project.
The human operator needs to reign the LLM which has a habbit of producing large amounts of code and documentation.
My experience is that code quality improves significantly by first do a planning step and ask critical questions: sway the LLM to look at the problem
from different angles. Always look for testing opportunities.
This requires domain knowledge - the more the better - which is of course the crux of the problem, but also true for traditional programming.
AGENTS.md details specifics for the project and is automatically picked up by most LLMs.