Replace bundled ROM extraction with runtime import/download flow
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)
@@ -0,0 +1,162 @@
|
||||
# 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 `Window`s on a shared stack, plus
|
||||
`src/pkjs/index.js` running inside Core for Pebble.
|
||||
|
||||

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

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

|
||||
|
||||
`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:
|
||||
|
||||
```bash
|
||||
npx @mermaid-js/mermaid-cli -i diagrams/<name>.mmd -o diagrams/<name>.png -b white -s 3
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# Debugging guide
|
||||
|
||||
How to get diagnostic output from both apps, plus a catalog of real bugs hit
|
||||
during development — each written as symptom → diagnosis → fix, since that's
|
||||
the order you'll actually encounter them in. See `CLAUDE.md` for build/install
|
||||
commands and `docs/architecture.md` for what each component does.
|
||||
|
||||
## 1. Getting logs
|
||||
|
||||
### Watch app
|
||||
|
||||
```bash
|
||||
pebble logs --phone <phone-ip>
|
||||
```
|
||||
|
||||
Streams both the C app's `APP_LOG`/`text_layer` state changes and the
|
||||
PebbleKit JS `console.log` output from inside Core for Pebble, interleaved.
|
||||
This is the only place JS errors (failed `fetch`, JSON parse errors,
|
||||
`sendAppMessage` failures) show up — they are otherwise silent on the watch.
|
||||
|
||||
### Companion app
|
||||
|
||||
```bash
|
||||
adb logcat | grep -E 'ViceJNI|SuM'
|
||||
```
|
||||
|
||||
- Tag `ViceJNI` (`vice_jni.c`) carries two kinds of lines:
|
||||
- Direct `LOGI`/`LOGE` calls from our own JNI code, e.g.
|
||||
`machine_read_snapshot → 0`.
|
||||
- Everything VICE itself prints to stdout/stderr, redirected through a pipe
|
||||
and re-emitted with a `VICE: ` prefix by `stderr_reader_thread`.
|
||||
**These two categories run on different threads** — see §3.3, it's a useful
|
||||
fact when you need to tell whether code is actually running on the VICE
|
||||
thread.
|
||||
- Tag `SuM` (`MainActivity.kt`, `TAG` constant) carries UI-level events: watch
|
||||
key presses, disk load/attach results, HTTP server start/stop.
|
||||
|
||||
## 2. Watch app pitfalls
|
||||
|
||||
### 2.1 App shows splash, then exits back to the watch's launcher
|
||||
|
||||
**Symptom:** the splash window appears, the timer fires, and instead of
|
||||
transitioning to the main screen the app quits entirely (back to the watch
|
||||
face or app list) — not a crash log, just gone.
|
||||
|
||||
**Diagnosis:** the Pebble window stack was briefly empty. The original splash
|
||||
implementation did:
|
||||
|
||||
```c
|
||||
static void splash_timeout_handler(void *data) {
|
||||
window_stack_push(s_main_window, true); // queued, not necessarily applied yet
|
||||
window_stack_remove(s_splash_window, false);
|
||||
}
|
||||
```
|
||||
|
||||
Pebble's own docs warn: *"If there are no windows for the app left on the
|
||||
stack, the app will be killed by the system, shortly."* Push and remove are
|
||||
not guaranteed to be atomic with respect to each other from the app's
|
||||
perspective, so this push-then-remove ordering can race.
|
||||
|
||||
**Fix:** never let the stack reach zero windows, even momentarily. Push the
|
||||
*replacement* window first and keep it on the stack permanently underneath
|
||||
the transient one, so the timeout handler only ever pops — it never has to
|
||||
push-and-remove in the same breath:
|
||||
|
||||
```c
|
||||
// init(): main_window pushed first, splash pushed on top of it.
|
||||
window_stack_push(s_main_window, true);
|
||||
...
|
||||
window_stack_push(s_splash_window, true);
|
||||
|
||||
// timeout handler: main_window is still underneath, so the stack never empties.
|
||||
static void splash_timeout_handler(void *data) {
|
||||
window_stack_remove(s_splash_window, true);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 AppMessage silently never arrives / `sendAppMessage failed`
|
||||
|
||||
**Symptom:** PebbleKit JS logs a `sendAppMessage failed` error, or nothing at
|
||||
all and the watch's `TextLayer` never updates.
|
||||
|
||||
**Diagnosis checklist:**
|
||||
- **Inbox too small.** The watch's `app_message_open(inboxSize, outboxSize)`
|
||||
inbox must fit the *largest* payload plus dictionary overhead. The screen
|
||||
mirror payload is up to ~2080 bytes (40×25 cells, up to 2 UTF-8 bytes each
|
||||
for umlaut overrides, + 25 newlines) — the inbox is opened at 2200 bytes to
|
||||
leave headroom. If you add a new larger payload type, bump this.
|
||||
- **Symbolic keys.** `{ 'SCREEN': value }` does not reliably resolve via
|
||||
`package.json`'s `messageKeys` under Core for Pebble. Always use the
|
||||
hardcoded numeric key, identically defined in both the C `enum` and
|
||||
`index.js` (`KEY_SCREEN = 2` in both places).
|
||||
- **`localhost` vs `127.0.0.1`.** Core for Pebble's JS runtime does not
|
||||
resolve `localhost` — `pkjs`'s `fetch`/`XMLHttpRequest` calls must target
|
||||
`127.0.0.1:8888`.
|
||||
|
||||
### 2.3 Pebble build fails with a Python traceback mentioning `relpath`
|
||||
|
||||
**Symptom:**
|
||||
|
||||
```
|
||||
AttributeError: 'NoneType' object has no attribute 'relpath'
|
||||
File ".../waflib/extras/process_sdk_resources.py", line 22, in find_most_specific_filename
|
||||
```
|
||||
|
||||
**Diagnosis:** the Pebble SDK's build system hardcodes the resource folder
|
||||
name to `resources/` (`bld.path.find_node("resources")` in
|
||||
`pebble_sdk.py`) — *not* `res/`. If `package.json`'s `resources.media[].file`
|
||||
points at a file that doesn't exist under a real top-level `resources/`
|
||||
folder, `resources_node` resolves to `None` and the path-join call inside the
|
||||
SDK throws this traceback instead of a normal "file not found" error.
|
||||
|
||||
**Fix:** keep all `media` resource files (PNGs, etc.) under a top-level
|
||||
`resources/` directory, never `res/`.
|
||||
|
||||
## 3. Companion app / VICE pitfalls
|
||||
|
||||
### 3.1 Snapshot restore: screen looks right, but buttons do nothing
|
||||
|
||||
**Symptom:** after `loadState()`, the saved screen renders correctly (memory,
|
||||
VIC-II, SID, CIA state are all genuinely restored), but the game never
|
||||
responds to keyboard/joystick input again.
|
||||
|
||||
**Diagnosis:** `maincpu_mainloop()` keeps the 6510's registers as
|
||||
**stack-local C variables** (`reg_pc`, `reg_a`, ...), not in the global
|
||||
`maincpu_regs` struct. They're synced only inside `DO_INTERRUPT()`'s
|
||||
`EXPORT_REGISTERS()` / `IMPORT_REGISTERS()` macros. Calling
|
||||
`machine_write_snapshot()` / `machine_read_snapshot()` directly from
|
||||
`video_canvas_refresh()` (i.e. outside an interrupt) means:
|
||||
- **Save** records whatever `maincpu_regs.pc` happened to hold from the *last*
|
||||
interrupt — stale, not the actual current PC.
|
||||
- **Load** writes the saved PC into `maincpu_regs.pc`, but the CPU loop keeps
|
||||
running from its own unrelated `reg_pc` — the restored PC never takes
|
||||
effect.
|
||||
|
||||
The CPU ends up executing from a PC that has nothing to do with the saved
|
||||
game state. Screen/CIA/SID content looks fine because *those* are restored
|
||||
correctly; only the actual instruction pointer is wrong, which silently
|
||||
breaks "the game keeps running but never processes input" without crashing.
|
||||
|
||||
**Fix:** route both save and load through `interrupt_maincpu_trigger_trap()`,
|
||||
so the snapshot call happens inside `DO_INTERRUPT(IK_TRAP)` and gets the
|
||||
`EXPORT_REGISTERS()`/`IMPORT_REGISTERS()` sync for free. See
|
||||
`save_state_trap()` / `load_state_trap()` in `vice_jni.c`, and
|
||||
`docs/architecture.md` §4.4 for the full sequence diagram.
|
||||
|
||||
**How this was actually diagnosed:** by comparing thread IDs in `adb logcat`.
|
||||
`LOGI("machine_read_snapshot → %d", r)` (called directly from inside the trap
|
||||
function) appeared on the same TID as `android_sound_close()`'s Android
|
||||
audio-stack log lines — i.e. the VICE/CPU thread. The `VICE: ...`-prefixed
|
||||
lines (from `stderr_reader_thread`) appeared on a *different* TID. That
|
||||
confirmed the trap really was running on the CPU thread, which ruled out a
|
||||
threading mistake and pointed at the register-sync logic instead.
|
||||
|
||||
### 3.2 `VICE: Sync reset` in the log is not a C64 reset
|
||||
|
||||
**Symptom:** `VICE: Sync reset` appears in logcat right after a snapshot
|
||||
load, looking like the machine just got reset (which would explain broken
|
||||
input — but doesn't, in fact).
|
||||
|
||||
**Diagnosis:** this line comes from `vsync_suspend_speed_eval()` in
|
||||
`vsync.c`, triggered when the sound device closes/reopens during the
|
||||
snapshot's `sound_snapshot_finish()` call. It resets internal frame-timing
|
||||
statistics only — it is unrelated to `machine_reset()`/`maincpu_reset()` and
|
||||
does not touch CPU or memory state. Don't chase it as the cause of a
|
||||
post-restore bug; it's noise.
|
||||
|
||||
### 3.3 `VICE: Error - T64 snapshot support is not implemented`
|
||||
|
||||
**Symptom:** this warning appears on every snapshot load, but
|
||||
`machine_read_snapshot()` still returns `0` (success).
|
||||
|
||||
**Diagnosis:** snapshots are saved with `save_disks=0` (disk management is
|
||||
handled by our own UI, not VICE's), so the drive module writes a minimal
|
||||
entry on save. On restore, `drive_snapshot_read_module()` logs this warning
|
||||
while parsing that minimal entry but still returns success — harmless.
|
||||
|
||||
### 3.4 Disk/reset/snapshot APIs must only be called from the VICE thread
|
||||
|
||||
**Symptom:** intermittent corruption or crashes after calling `loadDisk()`,
|
||||
`attachDisk()`, `resetMachine()`, `saveState()`, or `loadState()` directly
|
||||
from the calling (UI or HTTP) thread.
|
||||
|
||||
**Diagnosis:** all five funnel into mutex-guarded pending-path buffers
|
||||
(`g_pending_disk`, `g_pending_attach`, `g_pending_reset`,
|
||||
`g_pending_save_state`, `g_pending_load_state`) that `video_canvas_refresh()`
|
||||
drains once per rendered frame, on the VICE thread. This is intentional —
|
||||
`autostart_disk()`, `file_system_attach_disk()`, `machine_trigger_reset()`,
|
||||
and the snapshot calls are not safe to call cross-thread.
|
||||
|
||||
**Fix:** never call these JNI entry points' underlying VICE functions
|
||||
directly from Kotlin; always go through the existing pending-queue pattern
|
||||
in `vice_jni.c` if adding a new one.
|
||||
|
||||
### 3.5 `injectKey()` / `getScreenText()` have no locking — is that a bug?
|
||||
|
||||
No — this is deliberate, not a bug to "fix" if you notice it while reading
|
||||
the code. Both are called directly from non-VICE threads (the UI thread for
|
||||
on-screen keyboard taps, an HTTP worker thread for watch input and the screen
|
||||
mirror poll) without synchronizing with the VICE thread. Worst case, a race
|
||||
produces one stale keyboard-matrix bit or one stale screen byte for a single
|
||||
frame, which self-corrects on the next call. This is a different category
|
||||
from §3.1/§3.4: those are correctness-critical (a wrong PC or a cross-thread
|
||||
VICE API call corrupts state permanently), this isn't.
|
||||
|
||||
## 4. Isolating the watch UI from the companion app
|
||||
|
||||
To iterate on watch-side rendering/layout without a live companion app or
|
||||
phone, simulate the AppMessage payload directly against an emulator instance:
|
||||
|
||||
```bash
|
||||
pebble install --emulator emery --vnc
|
||||
pebble send-app-message --emulator emery --vnc --string 2="<40x25 test text>"
|
||||
pebble screenshot --vnc --no-open /tmp/screen.png
|
||||
```
|
||||
|
||||
(Key `2` is `KEY_SCREEN` — see the protocol table in `docs/architecture.md`.)
|
||||
This was how word-wrap and clipping behavior were verified across screen
|
||||
sizes (emery vs. basalt) before wiring up the real HTTP/VICE pipeline. Prefer
|
||||
testing on a real watch once the companion app is in the loop — the emulator
|
||||
is best for fast, isolated layout iteration, not for verifying end-to-end
|
||||
behavior.
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,16 @@
|
||||
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
|
||||
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,15 @@
|
||||
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
|
||||
|
After Width: | Height: | Size: 101 KiB |
@@ -0,0 +1,21 @@
|
||||
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
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,15 @@
|
||||
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
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,5 @@
|
||||
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)
|
||||
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,236 @@
|
||||
# Publishing guide
|
||||
|
||||
Steps to generate signing credentials and publish both apps. See
|
||||
`docs/architecture.md` for what each app does and `CLAUDE.md` for build
|
||||
commands.
|
||||
|
||||
## 0. Legal considerations — read before publishing either app
|
||||
|
||||
**The companion app no longer bundles Commodore ROM files.** The
|
||||
`kernal`, `basic`, `chargen`, and `1541` ROMs are still under copyright
|
||||
(commercial rights are held by Cloanto, who license them as part of "C64
|
||||
Forever"), so shipping them pre-installed would be copyright infringement,
|
||||
independent of Google Play's own policy on emulators.
|
||||
|
||||
The `extractViceRoms` Gradle task that used to copy these ROMs out of
|
||||
`res/vice-3.8.tar.gz` into `app/src/main/assets/` (and the auto-copy-on-first-
|
||||
launch logic in `MainActivity`) has been removed. Instead, on first launch
|
||||
`MainActivity.initEmulator()` checks `getExternalFilesDir(null)` for
|
||||
`kernal`/`basic`/`chargen`/`1541` and, if any are missing, shows a blocking
|
||||
dialog (`showRomImportDialog`) that lets the user pick their own ROM dump via
|
||||
the system file picker (the same Storage Access Framework flow already used
|
||||
for `.d64` disk imports). Picked files are matched to a canonical ROM name by
|
||||
filename substring (`romNameForFile`), so both VICE's versioned names (e.g.
|
||||
`kernal-901227-03.bin`) and plain renamed files work.
|
||||
|
||||
`res/extract_roms.sh` still exists for pulling the ROMs out of the bundled
|
||||
tarball into `res/roms/` (gitignored) for local testing/sideloading — it is
|
||||
no longer wired into the Gradle build and nothing under it ships in the APK.
|
||||
|
||||
The dialog also offers a **"Download ROMs"** button (`downloadRoms` /
|
||||
`extractRomsFromTarGz` in `MainActivity.kt`), which fetches VICE's own
|
||||
official source release —
|
||||
`https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz`
|
||||
(byte-identical to `res/vice-3.8.tar.gz`, verified by SHA-256) — and extracts
|
||||
the same four ROMs client-side. **This is a weaker legal position than the
|
||||
import flow, not a replacement for it:** the app is still facilitating
|
||||
acquisition of the ROMs over the network, rather than requiring the user to
|
||||
already possess a legally-obtained dump. It avoids *bundling* the ROMs in the
|
||||
APK (the Play Store policy trigger called out below), but if you want the
|
||||
strictest "bring your own ROM" posture for a public Play Store listing,
|
||||
consider removing this button before submission and keeping only the import
|
||||
path.
|
||||
|
||||
The actual game disk images (`versions/*.d64`, the commercial "Schwert und
|
||||
Magie" releases) are **not** bundled either — `CLAUDE.md` describes copying
|
||||
them onto the device manually via USB/adb. Keep it that way; never add a
|
||||
"download the game" path to either app.
|
||||
|
||||
The Rebble community store (watch app distribution) is far less strictly
|
||||
enforced, but the same legal exposure exists regardless of where the watch
|
||||
app is hosted, since the watch app only talks to the companion app — the ROM
|
||||
bundling was entirely a companion-app concern, now resolved.
|
||||
|
||||
## 1. .gitignore
|
||||
|
||||
Keystore files and credential properties must never be committed. Already
|
||||
added to `.gitignore`:
|
||||
|
||||
```
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
```
|
||||
|
||||
If you generate a keystore with a different name/extension, add that
|
||||
specific path too — don't rely on a broad glob you might forget to check.
|
||||
|
||||
## 2. Android companion app → Google Play
|
||||
|
||||
### 2.1 Generate an upload keystore
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore SchwertUndMagieOnPebbleCompanionApp/release.keystore \
|
||||
-alias sum-release \
|
||||
-keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
`keytool` will prompt for a keystore password, a key password (can be the
|
||||
same), and your name/org details for the certificate (these become public
|
||||
metadata in the signed APK, not secret). Store the keystore file and both
|
||||
passwords in a password manager — **losing this keystore means you can never
|
||||
publish an update to the same Play Store listing again** under the same app;
|
||||
Google cannot recover or reset it for you.
|
||||
|
||||
### 2.2 Store credentials in a gitignored properties file
|
||||
|
||||
Create `SchwertUndMagieOnPebbleCompanionApp/keystore.properties` (already
|
||||
gitignored, never commit it):
|
||||
|
||||
```properties
|
||||
storeFile=release.keystore
|
||||
storePassword=<keystore password>
|
||||
keyAlias=sum-release
|
||||
keyPassword=<key password>
|
||||
```
|
||||
|
||||
### 2.3 Wire the signing config
|
||||
|
||||
Add to `SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts`, near the
|
||||
top:
|
||||
|
||||
```kotlin
|
||||
import java.util.Properties
|
||||
|
||||
val keystoreProps = Properties().apply {
|
||||
rootProject.file("keystore.properties").takeIf { it.exists() }
|
||||
?.reader()?.use { load(it) }
|
||||
}
|
||||
```
|
||||
|
||||
Inside the `android { }` block:
|
||||
|
||||
```kotlin
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keystoreProps["storeFile"]?.let { storeFile = file(it as String) }
|
||||
storePassword = keystoreProps["storePassword"] as String?
|
||||
keyAlias = keystoreProps["keyAlias"] as String?
|
||||
keyPassword = keystoreProps["keyPassword"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// ...existing isMinifyEnabled / proguardFiles
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This degrades gracefully (null signing config values) on any machine without
|
||||
`keystore.properties` — CI or a contributor's checkout — rather than failing
|
||||
the whole Gradle configuration.
|
||||
|
||||
### 2.4 Build the release bundle
|
||||
|
||||
```bash
|
||||
cd SchwertUndMagieOnPebbleCompanionApp
|
||||
./gradlew bundleRelease # produces app/build/outputs/bundle/release/app-release.aab
|
||||
./gradlew assembleRelease # produces app/build/outputs/apk/release/app-release.apk, for sideload testing
|
||||
```
|
||||
|
||||
Play Store requires the **AAB** (`bundleRelease` output), not the APK — Play
|
||||
re-packages per-device APKs from it (including ABI splits, so `arm64-v8a`/
|
||||
`x86_64` native VICE libraries each ship only to matching devices).
|
||||
|
||||
### 2.5 Play Console setup (one-time, per app listing)
|
||||
|
||||
1. Enroll in the Google Play Developer program (one-time account fee).
|
||||
2. Create the app in Play Console, set its package name
|
||||
(`de.ladkau.schwertundmagieonpebblecompanionapp`) — this is permanent.
|
||||
3. **App content**: privacy policy URL, content rating questionnaire, data
|
||||
safety form (declare what data the app collects — this app's only network
|
||||
activity is the loopback HTTP server talking to Core for Pebble, so
|
||||
"no data collected/shared" likely applies, but fill out the form yourself).
|
||||
4. **Store listing**: title, short/full description, icon (512×512 PNG),
|
||||
feature graphic (1024×500), phone screenshots (min 2, current device's
|
||||
actual aspect ratio).
|
||||
5. Enroll in **Play App Signing** when prompted on first upload — Google
|
||||
re-signs your AAB with its own key for distribution; your upload keystore
|
||||
(§2.1) only needs to be kept for *future uploads to this listing*, not for
|
||||
the keys end users' devices actually trust.
|
||||
6. Upload the AAB to an **internal testing** track first, verify the install
|
||||
works on a real device, then promote to closed/open testing or production.
|
||||
|
||||
### 2.6 Versioning for future releases
|
||||
|
||||
Bump both fields in `app/build.gradle.kts` before every release build:
|
||||
|
||||
```kotlin
|
||||
versionCode = 2 // must strictly increase on every Play Store upload
|
||||
versionName = "1.1" // user-visible, free-form
|
||||
```
|
||||
|
||||
## 3. Pebble watch app → Rebble app store / direct distribution
|
||||
|
||||
The official Pebble app store shut down years ago; the community-run
|
||||
**Rebble** store is the closest equivalent today, alongside the 2025 Core
|
||||
Devices relaunch of Pebble hardware. Check Rebble's current developer portal
|
||||
directly for their exact submission flow and requirements — that's outside
|
||||
this repo and changes independently of it.
|
||||
|
||||
### 3.1 Build the artifact
|
||||
|
||||
```bash
|
||||
cd SchwertUndMagieOnPebbleWatchApp
|
||||
pebble build # produces build/SchwertUndMagieOnPebbleWatchApp.pbw
|
||||
```
|
||||
|
||||
The `.pbw` is the complete distributable — it bundles all target platforms
|
||||
(`aplite`/`basalt`/`chalk`/`diorite`/`emery`/`flint`/`gabbro`) declared in
|
||||
`package.json`. There is no signing step analogous to Android; Pebble apps
|
||||
are not cryptographically signed by the developer.
|
||||
|
||||
### 3.2 Direct distribution (no store)
|
||||
|
||||
Anyone with the `.pbw` file and Core for Pebble installed can sideload it —
|
||||
this is the lowest-friction path and doesn't depend on any third party's
|
||||
store being operational. This watch app is tightly coupled to the companion
|
||||
app's HTTP bridge (see `docs/architecture.md` §1), so it's not really
|
||||
meaningful as a standalone listing anyway — distribute both together.
|
||||
|
||||
### 3.3 Store submission (if Rebble's process applies)
|
||||
|
||||
Expect to need: an app icon resource (not yet configured in `package.json` —
|
||||
there's no `icon`/menu-icon entry currently, only the in-app `IMAGE_SPLASH`
|
||||
bitmap), a short description, and screenshots per platform. Generate
|
||||
screenshots the same way used during development:
|
||||
|
||||
```bash
|
||||
pebble install --emulator emery --vnc
|
||||
pebble screenshot --vnc --no-open docs/screenshots/emery.png
|
||||
```
|
||||
|
||||
(repeat per target platform you want a store screenshot for).
|
||||
|
||||
### 3.4 Versioning
|
||||
|
||||
Bump `version` in `SchwertUndMagieOnPebbleWatchApp/package.json` before each
|
||||
release build.
|
||||
|
||||
## 4. Pre-publish checklist
|
||||
|
||||
- [x] Resolved ROM bundling (§0) — companion app no longer ships/auto-installs
|
||||
copyrighted Commodore ROMs; it prompts the user to import their own dump
|
||||
- [ ] Release keystore generated, passwords saved in a password manager, both
|
||||
gitignored (§1, §2.1)
|
||||
- [ ] `keystore.properties` exists locally and is **not** tracked by git
|
||||
- [ ] `./gradlew bundleRelease` succeeds and installs/runs on a real device
|
||||
from the resulting AAB (test via `bundletool` or Play internal testing)
|
||||
- [ ] Play Console store listing content complete (icon, screenshots,
|
||||
privacy policy, content rating, data safety form)
|
||||
- [ ] `pebble build` succeeds for all target platforms; `.pbw` sideloads and
|
||||
runs correctly against the signed companion app build
|
||||
- [ ] `versionCode`/`versionName` (Android) and `version` (Pebble) bumped
|
||||