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)
7.8 KiB
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.
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.
- Splash —
BitmapLayershowingresources/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 anAppTimerafter 1.8s. - Main — a
ScrollLayerwrapping aTextLayerthat mirrors the C64 text screen. UP/DOWN are claimed entirely by the ScrollLayer's built-in click config (pan only); SELECT is added viascroll_layer_set_callbacks()'sclick_config_providerhook and opens the wheel. Content height is recomputed viagraphics_text_layout_get_content_size()every time new screen text arrives, since wrapped height varies frame to frame. - Wheel — a single large
TextLayercycling through a curated, navigation-only key set:1–9,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
- Main/UI thread —
MainActivity'sChoreographer.postFrameCallbackloop drives rendering: on every hardware vsync it callsdisplay.captureFrame(engine)(copies VICE's 320×200 ARGB framebuffer into a Bitmap) andinvalidate(). 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 viamainHandler) also run here. - VICE thread (
vice_threadinvice_jni.c) — runsmain_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 intog_framebuf. - NanoHTTPD worker thread(s) —
CompanionServer(inMainActivity.kt) serves/time,/key?cmd=,/screento Core for Pebble's JS.onKeyandgetScreenTextcall 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, prefixedVICE:. - OpenSL ES callback thread — pulls PCM samples from a ring buffer filled
by VICE's registered
androidsound 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)
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)
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)
4.4 Snapshot save/load (CPU-trap register sync)
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






