# 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 ```bash pebble logs --phone ``` 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 ```bash adb logcat | grep -E 'ViceJNI|SuM' ``` - Tag `ViceJNI` (`vice_jni.c`) carries two kinds of lines: - Direct `LOGI`/`LOGE` calls 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 by `stderr_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. - Tag `SuM` (`MainActivity.kt`, `TAG` constant) 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: ```c 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: ```c // 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 via `package.json`'s `messageKeys` under Core for Pebble. Always use the hardcoded numeric key, identically defined in both the C `enum` and `index.js` (`KEY_SCREEN = 2` in both places). - **`localhost` vs `127.0.0.1`.** Core for Pebble's JS runtime does not resolve `localhost` — `pkjs`'s `fetch`/`XMLHttpRequest` calls must target `127.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.pc` happened 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 unrelated `reg_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: ```bash 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.