# 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. ```mermaid graph LR subgraph Watch["Pebble Time 2 (watch)"] WatchC["Watch app (C)
splash / main / wheel windows"] end subgraph Phone["Android phone"] CFP["Core for Pebble
(PebbleKit JS runtime, separate app)"] subgraph Companion["Companion app process"] HTTP["NanoHTTPD server :8888"] UI["MainActivity / UI
C64DisplayView, C64KeyboardView"] JNI["JNI bridge
vice_jni.c"] VICE["VICE C64 core
(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 `Window`s on a shared stack, plus `src/pkjs/index.js` running inside Core for Pebble. ```mermaid 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** — `BitmapLayer` 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: `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 ```mermaid graph TD Main["Main/UI thread
Choreographer vsync loop, touch events"] Vice["VICE thread
main_program() → maincpu_mainloop()"] Http["NanoHTTPD worker thread(s)
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`'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) ```mermaid 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) ```mermaid 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) ```mermaid 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 = "