Files
ml aaa6e772a7 Replace bundled ROM extraction with runtime import/download flow
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)
2026-06-25 09:27:19 +02:00

7.8 KiB
Raw Permalink Blame History

Architecture

This document describes the current runtime architecture of the two apps in this repo: the Pebble watch app and the Android companion app. For build/install commands, see CLAUDE.md.

1. System overview

Three processes cooperate across two devices. Core for Pebble is a separate app on the phone (not part of this repo) that bridges Bluetooth AppMessage traffic to a JS runtime; our companion app talks to it only via loopback HTTP.

System overview diagram

2. Pebble watch app

SchwertUndMagieOnPebbleWatchApp/src/c/SchwertUndMagieOnPebbleFrontend.c is a single-file C watchapp built around three Windows on a shared stack, plus src/pkjs/index.js running inside Core for Pebble.

Watch app window stack state diagram

  • SplashBitmapLayer showing resources/splash.png, centered, black backdrop. Pushed on top of the already-pushed Main window at startup (not in place of an empty stack — removing the last window on the stack kills the app), then removed by an AppTimer after 1.8s.
  • Main — a ScrollLayer wrapping a TextLayer that mirrors the C64 text screen. UP/DOWN are claimed entirely by the ScrollLayer's built-in click config (pan only); SELECT is added via scroll_layer_set_callbacks()'s click_config_provider hook and opens the wheel. Content height is recomputed via graphics_text_layout_get_content_size() every time new screen text arrives, since wrapped height varies frame to frame.
  • Wheel — a single large TextLayer cycling through a curated, navigation-only key set: 19, 0, RETURN, SPACE (movement in this game is mostly done with number keys). UP/DOWN rotate the index, SELECT sends the highlighted label and pops back to Main, BACK cancels for free (default window-pop behavior, left unsubscribed).

AppMessage protocol

Key Value Direction Payload
TIME 0 Unused (legacy; originally the clock, replaced by SCREEN)
COMMAND 1 watch → phone Wheel item label: one of 1..9, 0, RETURN, SPACE
SCREEN 2 phone → watch UTF-8 C64 screen text, 40×25 cells, \n per row

Numeric keys are hardcoded identically in the C app and index.js — symbolic key resolution from package.json's messageKeys is unreliable with Core for Pebble. The watch's AppMessage inbox is opened at 2200 bytes to fit the worst-case screen payload (40×25 cells × up to 2 UTF-8 bytes for umlaut overrides, + 25 newlines).

3. Android companion app

Companion app thread diagram

  • Main/UI threadMainActivity's Choreographer.postFrameCallback loop drives rendering: on every hardware vsync it calls display.captureFrame(engine) (copies VICE's 320×200 ARGB framebuffer into a Bitmap) and invalidate(). VICE itself runs continuously and asynchronously in its own thread, decoupled from this vsync sampling. Touch input (C64KeyboardView, disk drawer buttons) and NanoHTTPD callbacks (marshaled via mainHandler) also run here.
  • VICE thread (vice_thread in vice_jni.c) — runs main_program()maincpu_mainloop(), VICE's own CPU/VICII loop. video_canvas_refresh() is our hook into this loop, called once per rendered region: it drains mutex-guarded pending-operation queues (disk autostart/attach, hard reset, snapshot save/load) written from the UI or HTTP threads, then renders into g_framebuf.
  • NanoHTTPD worker thread(s)CompanionServer (in MainActivity.kt) serves /time, /key?cmd=, /screen to Core for Pebble's JS. onKey and getScreenText call directly into the JNI layer from this thread (see §5 on tolerated races).
  • stdout/stderr reader threads — pipe VICE's redirected stdout/stderr to Logcat under tag ViceJNI, prefixed VICE: .
  • OpenSL ES callback thread — pulls PCM samples from a ring buffer filled by VICE's registered android sound driver.

Key source files

File Role
MainActivity.kt UI, Choreographer render loop, NanoHTTPD server, disk drawer, watch key wheel → injectKey mapping
C64Engine.kt JNI external-function declarations + C64 keyboard matrix constants
C64DisplayView.kt Double-buffered View blitting the 320×200 ARGB framebuffer
C64KeyboardView.kt On-screen virtual C64 keyboard (multi-touch, sticky shift)
vice_jni.c VICE integration: thread management, video/sound drivers, pending-op queues, snapshot CPU-trap dispatch, getScreenText()

4. Data flows

4.1 Screen mirror (VICE → watch)

Screen mirror sequence diagram

getScreenText() reads C64 screen RAM at the fixed default address $0400 (same assumption autostart.c makes when checking for KERNAL "READY." text — this game never relocates the VIC-II screen pointer) and converts each screencode → PETSCII → ASCII via VICE's own charset.c tables. The game uploads a custom character set that redefines a consecutive run of otherwise-unused screencodes to draw German umlauts; a small override table in getScreenText() catches these and emits proper UTF-8 before falling through to the standard conversion:

Screencode Stock glyph Overridden to
0x1B [ ä
0x1C £ ö
0x1D ] ü
0x1E ß

4.2 On-screen keyboard input (phone touch → VICE)

On-screen keyboard input sequence diagram

Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are pressed/released together.

4.3 Watch key wheel input (watch → VICE)

Watch key wheel input sequence diagram

4.4 Snapshot save/load (CPU-trap register sync)

Snapshot save/load CPU-trap sequence diagram

Both save and load must run inside a CPU trap. maincpu_mainloop() keeps CPU registers as stack-local variables (reg_pc, reg_a, ...), syncing them with the global maincpu_regs struct only via EXPORT_REGISTERS() / IMPORT_REGISTERS() inside DO_INTERRUPT. Calling machine_write_snapshot/ machine_read_snapshot directly from video_canvas_refresh() (outside a trap) would read/write a stale maincpu_regs.pc — the snapshot would record (or restore) the wrong program counter, leaving the CPU executing from the wrong address after a load even though screen/CIA/SID state all looked correct.

4.5 Disk load / attach / reset

loadDisk() (full reset + autostart, used for A-side episode disks) and attachDisk() (hot-swap, used for B-side/hero disks) both just write a path into a mutex-guarded pending buffer; video_canvas_refresh() drains it on the VICE thread and calls autostart_disk() or file_system_attach_disk() accordingly — disk and reset APIs, like snapshot APIs, must only be called from the VICE thread.

5. Tolerated cross-thread races

Two JNI calls are invoked directly from non-VICE threads with no locking: injectKey() (writes the keyboard matrix from the UI thread or an HTTP worker thread) and getScreenText() (reads screen RAM from an HTTP worker thread). Both are deliberate: a keyboard matrix write or a screen-text read racing with the VICE thread can produce at most one stale byte for one frame, which self-corrects on the next poll/keypress — acceptable for display and input purposes. This is a different category from §4.4: snapshot register sync is correctness-critical (a wrong PC corrupts execution permanently), so it goes through the CPU trap; keyboard/display reads are not, so they don't.


Diagrams are rendered PNGs under diagrams/; each has a matching .mmd Mermaid source in the same folder. To regenerate one after editing its source:

npx @mermaid-js/mermaid-cli -i diagrams/<name>.mmd -o diagrams/<name>.png -b white -s 3