Commodore ROMs are copyrighted and can no longer ship inside the APK. MainActivity now detects missing ROMs on first launch and blocks startup with a dialog offering two paths: pick files via the system file picker, or download the official VICE 3.8 tarball and extract the four ROMs from it client-side (minimal USTAR reader, no extra deps). - Drop the extractViceRoms Gradle task and asset bundling; add res/extract_roms.sh for local sideloading during development instead - gitignore keystores/keystore.properties ahead of a signed release - Move architecture.md into docs/, refresh it for the screen-mirroring and watch-input additions, and add accompanying Mermaid diagrams - Add docs/debugging.md and docs/publish.md (Play Store release notes, ROM-import compliance rationale)
10 KiB
Debugging guide
How to get diagnostic output from both apps, plus a catalog of real bugs hit
during development — each written as symptom → diagnosis → fix, since that's
the order you'll actually encounter them in. See CLAUDE.md for build/install
commands and docs/architecture.md for what each component does.
1. Getting logs
Watch app
pebble logs --phone <phone-ip>
Streams both the C app's APP_LOG/text_layer state changes and the
PebbleKit JS console.log output from inside Core for Pebble, interleaved.
This is the only place JS errors (failed fetch, JSON parse errors,
sendAppMessage failures) show up — they are otherwise silent on the watch.
Companion app
adb logcat | grep -E 'ViceJNI|SuM'
- Tag
ViceJNI(vice_jni.c) carries two kinds of lines:- Direct
LOGI/LOGEcalls from our own JNI code, e.g.machine_read_snapshot → 0. - Everything VICE itself prints to stdout/stderr, redirected through a pipe
and re-emitted with a
VICE:prefix bystderr_reader_thread. These two categories run on different threads — see §3.3, it's a useful fact when you need to tell whether code is actually running on the VICE thread.
- Direct
- Tag
SuM(MainActivity.kt,TAGconstant) carries UI-level events: watch key presses, disk load/attach results, HTTP server start/stop.
2. Watch app pitfalls
2.1 App shows splash, then exits back to the watch's launcher
Symptom: the splash window appears, the timer fires, and instead of transitioning to the main screen the app quits entirely (back to the watch face or app list) — not a crash log, just gone.
Diagnosis: the Pebble window stack was briefly empty. The original splash implementation did:
static void splash_timeout_handler(void *data) {
window_stack_push(s_main_window, true); // queued, not necessarily applied yet
window_stack_remove(s_splash_window, false);
}
Pebble's own docs warn: "If there are no windows for the app left on the stack, the app will be killed by the system, shortly." Push and remove are not guaranteed to be atomic with respect to each other from the app's perspective, so this push-then-remove ordering can race.
Fix: never let the stack reach zero windows, even momentarily. Push the replacement window first and keep it on the stack permanently underneath the transient one, so the timeout handler only ever pops — it never has to push-and-remove in the same breath:
// init(): main_window pushed first, splash pushed on top of it.
window_stack_push(s_main_window, true);
...
window_stack_push(s_splash_window, true);
// timeout handler: main_window is still underneath, so the stack never empties.
static void splash_timeout_handler(void *data) {
window_stack_remove(s_splash_window, true);
}
2.2 AppMessage silently never arrives / sendAppMessage failed
Symptom: PebbleKit JS logs a sendAppMessage failed error, or nothing at
all and the watch's TextLayer never updates.
Diagnosis checklist:
- Inbox too small. The watch's
app_message_open(inboxSize, outboxSize)inbox must fit the largest payload plus dictionary overhead. The screen mirror payload is up to ~2080 bytes (40×25 cells, up to 2 UTF-8 bytes each for umlaut overrides, + 25 newlines) — the inbox is opened at 2200 bytes to leave headroom. If you add a new larger payload type, bump this. - Symbolic keys.
{ 'SCREEN': value }does not reliably resolve viapackage.json'smessageKeysunder Core for Pebble. Always use the hardcoded numeric key, identically defined in both the Cenumandindex.js(KEY_SCREEN = 2in both places). localhostvs127.0.0.1. Core for Pebble's JS runtime does not resolvelocalhost—pkjs'sfetch/XMLHttpRequestcalls must target127.0.0.1:8888.
2.3 Pebble build fails with a Python traceback mentioning relpath
Symptom:
AttributeError: 'NoneType' object has no attribute 'relpath'
File ".../waflib/extras/process_sdk_resources.py", line 22, in find_most_specific_filename
Diagnosis: the Pebble SDK's build system hardcodes the resource folder
name to resources/ (bld.path.find_node("resources") in
pebble_sdk.py) — not res/. If package.json's resources.media[].file
points at a file that doesn't exist under a real top-level resources/
folder, resources_node resolves to None and the path-join call inside the
SDK throws this traceback instead of a normal "file not found" error.
Fix: keep all media resource files (PNGs, etc.) under a top-level
resources/ directory, never res/.
3. Companion app / VICE pitfalls
3.1 Snapshot restore: screen looks right, but buttons do nothing
Symptom: after loadState(), the saved screen renders correctly (memory,
VIC-II, SID, CIA state are all genuinely restored), but the game never
responds to keyboard/joystick input again.
Diagnosis: maincpu_mainloop() keeps the 6510's registers as
stack-local C variables (reg_pc, reg_a, ...), not in the global
maincpu_regs struct. They're synced only inside DO_INTERRUPT()'s
EXPORT_REGISTERS() / IMPORT_REGISTERS() macros. Calling
machine_write_snapshot() / machine_read_snapshot() directly from
video_canvas_refresh() (i.e. outside an interrupt) means:
- Save records whatever
maincpu_regs.pchappened to hold from the last interrupt — stale, not the actual current PC. - Load writes the saved PC into
maincpu_regs.pc, but the CPU loop keeps running from its own unrelatedreg_pc— the restored PC never takes effect.
The CPU ends up executing from a PC that has nothing to do with the saved game state. Screen/CIA/SID content looks fine because those are restored correctly; only the actual instruction pointer is wrong, which silently breaks "the game keeps running but never processes input" without crashing.
Fix: route both save and load through interrupt_maincpu_trigger_trap(),
so the snapshot call happens inside DO_INTERRUPT(IK_TRAP) and gets the
EXPORT_REGISTERS()/IMPORT_REGISTERS() sync for free. See
save_state_trap() / load_state_trap() in vice_jni.c, and
docs/architecture.md §4.4 for the full sequence diagram.
How this was actually diagnosed: by comparing thread IDs in adb logcat.
LOGI("machine_read_snapshot → %d", r) (called directly from inside the trap
function) appeared on the same TID as android_sound_close()'s Android
audio-stack log lines — i.e. the VICE/CPU thread. The VICE: ...-prefixed
lines (from stderr_reader_thread) appeared on a different TID. That
confirmed the trap really was running on the CPU thread, which ruled out a
threading mistake and pointed at the register-sync logic instead.
3.2 VICE: Sync reset in the log is not a C64 reset
Symptom: VICE: Sync reset appears in logcat right after a snapshot
load, looking like the machine just got reset (which would explain broken
input — but doesn't, in fact).
Diagnosis: this line comes from vsync_suspend_speed_eval() in
vsync.c, triggered when the sound device closes/reopens during the
snapshot's sound_snapshot_finish() call. It resets internal frame-timing
statistics only — it is unrelated to machine_reset()/maincpu_reset() and
does not touch CPU or memory state. Don't chase it as the cause of a
post-restore bug; it's noise.
3.3 VICE: Error - T64 snapshot support is not implemented
Symptom: this warning appears on every snapshot load, but
machine_read_snapshot() still returns 0 (success).
Diagnosis: snapshots are saved with save_disks=0 (disk management is
handled by our own UI, not VICE's), so the drive module writes a minimal
entry on save. On restore, drive_snapshot_read_module() logs this warning
while parsing that minimal entry but still returns success — harmless.
3.4 Disk/reset/snapshot APIs must only be called from the VICE thread
Symptom: intermittent corruption or crashes after calling loadDisk(),
attachDisk(), resetMachine(), saveState(), or loadState() directly
from the calling (UI or HTTP) thread.
Diagnosis: all five funnel into mutex-guarded pending-path buffers
(g_pending_disk, g_pending_attach, g_pending_reset,
g_pending_save_state, g_pending_load_state) that video_canvas_refresh()
drains once per rendered frame, on the VICE thread. This is intentional —
autostart_disk(), file_system_attach_disk(), machine_trigger_reset(),
and the snapshot calls are not safe to call cross-thread.
Fix: never call these JNI entry points' underlying VICE functions
directly from Kotlin; always go through the existing pending-queue pattern
in vice_jni.c if adding a new one.
3.5 injectKey() / getScreenText() have no locking — is that a bug?
No — this is deliberate, not a bug to "fix" if you notice it while reading the code. Both are called directly from non-VICE threads (the UI thread for on-screen keyboard taps, an HTTP worker thread for watch input and the screen mirror poll) without synchronizing with the VICE thread. Worst case, a race produces one stale keyboard-matrix bit or one stale screen byte for a single frame, which self-corrects on the next call. This is a different category from §3.1/§3.4: those are correctness-critical (a wrong PC or a cross-thread VICE API call corrupts state permanently), this isn't.
4. Isolating the watch UI from the companion app
To iterate on watch-side rendering/layout without a live companion app or phone, simulate the AppMessage payload directly against an emulator instance:
pebble install --emulator emery --vnc
pebble send-app-message --emulator emery --vnc --string 2="<40x25 test text>"
pebble screenshot --vnc --no-open /tmp/screen.png
(Key 2 is KEY_SCREEN — see the protocol table in docs/architecture.md.)
This was how word-wrap and clipping behavior were verified across screen
sizes (emery vs. basalt) before wiring up the real HTTP/VICE pipeline. Prefer
testing on a real watch once the companion app is in the loop — the emulator
is best for fast, isolated layout iteration, not for verifying end-to-end
behavior.