make/common.mk assigned VICE_HOME and the other emulator homes with :=, and a makefile assignment beats the environment - so exporting VICE_HOME was silently blanked and "make plus4 test" ran a bare xplus4 that is not on PATH. They are ?= now. The value is a prefix, so it needs its trailing slash. A port name only selects a port, so give it an empty recipe rather than let make print "Nothing to be done for `plus4'" in front of a test run that is about to work perfectly well. recipes/ gets the variables that are easy to lose and fail quietly: RETRO68 for mac68k, PATH and ZCCCFG for spectrum - zcc compiles and links the whole program without ZCCCFG and only the .sna step fails, which reads like a broken install - and the emulator homes for running. Also that Basilisk holding cc65-Chess.dsk open fails the mac68k build at hformat with a lock error that looks like a broken toolchain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
Debugging the plus4 build
Written 2026-08-07, after "there is a plus4 build but it goes sideways right
away." Companion to
apple2-debugging.md; the two machines need different instruments but the same
discipline.
The headline finding is that "goes sideways" was two independent faults stacked on top of each other, and each one hid the other. Getting that wrong in either direction — blaming the emulator for both, or the program for both — was available at every step.
1. The instrument
xplus4 ships alongside x64sc in /Applications/vice-arm64-gtk3-3.10/bin/.
VICE's binary monitor is the usable interface; the text monitor is fragile
about reconnects and cannot do half of this.
That directory is not on PATH, so make plus4 test (and make c64 test)
find it through VICE_HOME, which is a prefix and therefore needs its
trailing slash:
export VICE_HOME=/Applications/vice-arm64-gtk3-3.10/bin/
Until 2026-08-16 exporting it did nothing: make/common.mk assigned the
emulator homes with :=, and a makefile assignment beats the environment, so
the recipe always ran a bare xplus4 and reported make: xplus4: No such file or directory. They are ?= now. The same applies to CX16_HOME,
AWIN_HOME, ORIC_HOME, ATARI_HOME and RP6502_HOME.
Read ../c64m/agents/vice-oracle.md first. It is written for c64m-vs-VICE
comparison, but its protocol section and its list of traps apply unchanged. VICE
source, for reading what the Kernal actually does, is at
/Users/swessels/Develop/svm/vice-emu-code/vice.
xplus4 -TEDdsize -autostart build/plus4/cc65-Chess \
-binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502
There is no ready-made Python client — ../c64m/tools has one for the text
monitor only. vice/vicebin.py next to this file is a minimal one written from
the wire format in the oracle doc; vice/shot.py renders DISPLAY_GET to PNG,
and vice/plus4run.py is the deterministic launcher described in §4.
Two corrections to the oracle doc's command table, found the hard way:
REGISTERS_SET is 0x32 and takes a leading <u8 memspace> exactly like
REGISTERS_GET — without it you get error 128, "command length not correct" —
and MEM_SET is 0x02.
Addresses worth having: keyboard buffer $0527 (8 bytes), KEY_COUNT (NDX)
$00EF, FKEY_COUNT $055D, function-key strings $055F, KBDREAD $D8C1,
screen RAM $0C00, cc65 BASIC stub entry $100D. Palette for rendering frames:
.../share/vice/PLUS4/yape-pal.vpl.
2. Two rules that are not optional
Closing the socket resumes emulation. This is in the oracle doc and it still cost an hour. Anything measured across two script invocations has a gap in the middle where the machine ran unobserved, and the reading you get back describes a machine that has moved on. It produced two confident wrong conclusions here: a "crash" that was really the machine having run for ten more seconds, and a screenshot of a failure that had already been superseded. One connection per experiment, from setup to final capture.
Never believe a negative without a control. "The checkpoint at $100D never
fires" is worth nothing until a checkpoint on an address the machine
demonstrably executes does fire. The control here was: break in, read PC, arm a
checkpoint at that same PC, resume, confirm the hit. Thirty seconds, and it is
what turned "I couldn't get it to stop" into evidence.
3. Fault one — VICE's autostart never starts the program
The program loads perfectly: $1001–$7182, byte-identical to the file. Only
the RUN fails. An exec checkpoint on $100D never fires under -autostart, and
that holds for all four -autostartprgmode values.
A store watchpoint on the keyboard buffer shows why:
0 buf=|RUN.....| NDX= 3 VICE feeds "RUN"
1 buf=|UUN.....| NDX= 3 kernal shifts the buffer down
3 buf=|........| NDX= 1
4 buf=|........| NDX= 1 the CR arrives as a separate async write
6 NDX= 0
7 NDX=255 underflow
VICE writes the buffer and the count directly from its vsync hook, racing the
Kernal's read-modify-write of $EF. The count wraps, and the Kernal then "types"
~255 bytes of the RAM that follows the 8-byte buffer. That RAM is the
function-key macro table, which is why the screen fills with DSAVE", LOAD"
and DIRECTORY, and why the machine ends up hung in the Kernal SAVE routine at
$ED08 waiting for a device that does not exist.
Control that proves it is VICE and not the machine: with no VICE injection at
all, hand-writing RUN\r and a count of 4 into the buffer while stopped drains
4→3→2→1→0 and stops. No underflow. The Kernal is fine.
-autostart-delay 40 reaches the entry point reliably (3/3). It is a workaround
for the launch, not a fix for the race.
4. Starting it deterministically
Since the launch is what is broken, do not use it. vice/plus4run.py:
- Boot with no autostart at all.
- Write the PRG into RAM with
MEM_SET(2 KB chunks) and verify it back. - Set the BASIC pointers at
$2D/$2F/$31to the end address. - Arm a checkpoint at
$100D. - Write
RUN\rand count 4 into the keyboard buffer while stopped, then resume.
No VICE keyboard injection anywhere, no race, and the checkpoint confirms the entry was genuinely reached rather than assumed.
5. Fault two — the program's own keyboard path
This one survives a perfectly clean start, which is what makes it real. With
zero VICE involvement, KEY_COUNT goes 0 → 255 immediately after entry and
keeps decrementing. A store watchpoint names the writer: $D8E6, which is inside
the ROM's KBDREAD at $D8C1.
Sampling the state either side of it gives the whole story:
at entry $100D KEY_COUNT= 0 FKEY_COUNT=0 FKEY_SPACE=12060a0706040505
at 1st $EF write KEY_COUNT=255 FKEY_COUNT=0 FKEY_SPACE=0101010101010185
at 2nd $EF write KEY_COUNT=254 FKEY_COUNT=1 FKEY_SPACE=0101010101018589
The first reading of this blamed initkbd — it rewrites the function-key table
(that is the 12060a… → 0101… change above) with interrupts enabled, so the
theory was that the Kernal's scanner saw the table mid-rewrite and left
FKEY_COUNT at 1, which cgetc would then act on. It is a tidy story and it is
wrong. Its own evidence contradicts it: at the first underflow FKEY_COUNT
was 0, so the KEY_COUNT | FKEY_COUNT gate could not have passed.
The real defect is four lines further down libsrc/plus4/cgetc.s:
_cgetc: lda KEY_COUNT ; $EF
ora FKEY_COUNT ; $055D
bne L2 ; something waiting -> go read it
lda #%00100000
bit $FF06
bne L2 ; always disable cursor if in bitmap mode
... ; cursor on, then L1 waits for a key
L2: jsr KBDREAD ; decrements KEY_COUNT
The second bne L2 means to skip the cursor code in bitmap mode, since there
is no text cursor to draw. What it actually skips is the L1 wait loop as well,
landing on KBDREAD with nothing in the buffer. So in bitmap mode cgetc()
never blocks, always returns a character that does not exist, and takes
KEY_COUNT from 0 to 255 on the first call. From then on every key read returns
garbage, the menus navigate themselves, the game reaches Quit and returns to
BASIC. That is the "goes sideways", and the plus4 chess build is in bitmap mode
throughout.
This is a cc65 bug, not ours. Two four-line programs, identical apart from one setting TED bitmap mode, isolate it with no chess code present:
int main(void) { for(;;) cgetc(); } /* KEY_COUNT stays 0 */
int main(void) { *(unsigned char*)0xFF06 |= 0x20; /* bitmap mode */
for(;;) cgetc(); } /* KEY_COUNT trashed */
kbhit() is a narrower version of the same hazard: it only tests, so it never
underflows anything itself, but it reports true on FKEY_COUNT alone, and the
cgetc() that follows then decrements KEY_COUNT.
The workaround (in plat_ReadKeys) is to check KEY_COUNT directly and call
cgetc() only when a character is genuinely in the buffer — which keeps the
bitmap-mode shortcut harmless, because the first gate now passes legitimately.
Verified: KEY_COUNT stays 0, the About screen waits for a key instead of
running away, and one injected keypress brings up the board and menu. 3/3 over a
normal -autostart-delay 40 launch, where before the fix it exited to BASIC
every time.
It is a workaround, not a fix. cc65 still has the bug, and it will bite any
plus4 program that calls cgetc() in bitmap mode.
The fix (in plat_ReadKeys) is to stop trusting cc65's gate and check the
real count, so cgetc() is only ever called with a character actually in the
buffer. Verified: KEY_COUNT stays 0, the About screen waits for a key instead
of running away, and one injected keypress brings up the board and menu. 3/3 over
a normal -autostart-delay 40 launch, where before the fix it exited to BASIC
every time.
6. Order of operations for the next one
- Separate the launch from the program before anything else. Check whether the entry point is reached at all, with a control. Half of what looked like a program bug here was the emulator never starting it, and the other half was only visible once that was out of the way.
- Dump memory and compare against the file. "The program loaded correctly" is two seconds of work and rules out an enormous amount.
- Hold one connection. Capture state and screen in the same script run as the thing you are measuring.
- When a count or index behaves impossibly, put a store watchpoint on the single address and read the PC. Naming the writer ended both halves of this investigation.
7. Noticed in passing, not acted on
The plus4 About screen reads "Commodore 64 graphics version, 2020."
(platPlus4.c:113), copied verbatim from platC64.c:102. Probably deliberate,
since the plus4 uses the c64 piece graphics, but it does say Commodore 64 on a
Plus/4.