Files
schwert_und_magie_on_pebble/architecture.md
T
ml 89c99b50e2 Add screen mirroring, watch input, splash screen, and architecture docs
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.
2026-06-21 19:15:19 +02:00

11 KiB
Raw 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.

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)
  • 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

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 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)

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.