Mirrors the C64 text screen to the Pebble watch (with game-specific umlaut handling), adds a SELECT-triggered key wheel for sending navigation input back to the emulator, shows a splash screen on watch launch, and documents the resulting end-to-end architecture.
11 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.
graph LR
subgraph Watch["Pebble Time 2 (watch)"]
WatchC["Watch app (C)<br/>splash / main / wheel windows"]
end
subgraph Phone["Android phone"]
CFP["Core for Pebble<br/>(PebbleKit JS runtime, separate app)"]
subgraph Companion["Companion app process"]
HTTP["NanoHTTPD server :8888"]
UI["MainActivity / UI<br/>C64DisplayView, C64KeyboardView"]
JNI["JNI bridge<br/>vice_jni.c"]
VICE["VICE C64 core<br/>(own pthread)"]
end
end
WatchC <-->|Bluetooth AppMessage| CFP
CFP <-->|HTTP loopback 127.0.0.1:8888| HTTP
HTTP --> UI
UI --> JNI
JNI <--> VICE
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.
stateDiagram-v2
[*] --> Splash
Splash --> Main: 1800ms timer\n(window_stack_remove splash)
Main --> Wheel: SELECT\n(push wheel)
Wheel --> Main: SELECT (send + pop)\nor BACK (cancel, pop)
- 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
graph TD
Main["Main/UI thread<br/>Choreographer vsync loop, touch events"]
Vice["VICE thread<br/>main_program() → maincpu_mainloop()"]
Http["NanoHTTPD worker thread(s)<br/>one per request"]
StdoutT["stdout reader thread"]
StderrT["stderr reader thread"]
Audio["OpenSL ES callback thread"]
Main -->|"captureFrame(): reads g_framebuf"| Vice
Main -->|"injectKey(): writes keyboard matrix"| Vice
Http -->|"onKey → injectWatchKey → injectKey"| Vice
Http -->|"getScreenText(): reads C64 RAM"| Vice
Vice -->|"writes g_framebuf, drains pending queues"| Main
Vice -->|stdout/stderr pipes| StdoutT
Vice -->|stdout/stderr pipes| StderrT
Vice -->|sound ring buffer| Audio
- 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)
sequenceDiagram
participant VICE as VICE thread
participant JNI as vice_jni.c
participant HTTP as NanoHTTPD /screen
participant JS as PebbleKit JS
participant Watch as Watch app (KEY_SCREEN)
loop every 1s
JS->>HTTP: GET /screen
HTTP->>JNI: getScreenText()
JNI->>VICE: mem_read_screen($0400..$07E7)
JNI-->>HTTP: UTF-8 text (40x25, \n per row)
HTTP-->>JS: {"text": "..."}
JS->>Watch: AppMessage KEY_SCREEN
Watch->>Watch: update ScrollLayer/TextLayer
end
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)
sequenceDiagram
participant User
participant KB as C64KeyboardView
participant Main as MainActivity
participant JNI as vice_jni.c
participant VICE as VICE keyboard matrix
User->>KB: touch down/up on key
KB->>Main: onKeyEvent(key, pressed)
Main->>JNI: engine.injectKey(code, pressed) (per code, for composites)
JNI->>VICE: keyboard_set_keyarr(row, col, pressed)
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)
sequenceDiagram
participant Watch
participant JS as PebbleKit JS
participant HTTP as NanoHTTPD /key
participant Main as MainActivity
participant JNI as vice_jni.c
Watch->>Watch: SELECT opens wheel; UP/DOWN rotate; SELECT confirms
Watch->>JS: AppMessage KEY_COMMAND = "<label>"
JS->>HTTP: GET /key?cmd=<label>
HTTP->>Main: onKey(cmd)
Main->>Main: watchKeyMap[cmd] → codes
Main->>JNI: injectKey(code, true) for each code
Main->>Main: postDelayed 80ms
Main->>JNI: injectKey(code, false) for each code
4.4 Snapshot save/load (CPU-trap register sync)
sequenceDiagram
participant UI as MainActivity (save/load button)
participant Pending as g_pending_save_state / g_pending_load_state
participant Refresh as video_canvas_refresh (VICE thread)
participant Trap as interrupt_maincpu_trigger_trap
participant CPU as 6510core.c DO_INTERRUPT(IK_TRAP)
UI->>Pending: saveState(path) / loadState(path) [mutex-guarded]
Refresh->>Pending: drain pending path next frame
Refresh->>Trap: schedule save_state_trap / load_state_trap
CPU->>CPU: EXPORT_REGISTERS()
CPU->>Trap: run trap function
Trap->>Trap: machine_write_snapshot() / machine_read_snapshot()
CPU->>CPU: IMPORT_REGISTERS()
Note over CPU: reg_pc now matches the saved/restored maincpu_regs.pc
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.