89c99b50e2
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.
260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# 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)<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 `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<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`'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 = "<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)
|
||
|
||
```mermaid
|
||
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.
|