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)
This commit is contained in:
ml
2026-06-25 09:27:19 +02:00
parent 89c99b50e2
commit aaa6e772a7
26 changed files with 893 additions and 172 deletions
+162
View File
@@ -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.
![System overview diagram](diagrams/system-overview.png)
## 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.
![Watch app window stack state diagram](diagrams/watch-window-stack.png)
- **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
![Companion app thread diagram](diagrams/companion-threads.png)
- **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)
![Screen mirror sequence diagram](diagrams/screen-mirror-flow.png)
`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)
![On-screen keyboard input sequence diagram](diagrams/keyboard-input-flow.png)
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)
![Watch key wheel input sequence diagram](diagrams/watch-wheel-input-flow.png)
### 4.4 Snapshot save/load (CPU-trap register sync)
![Snapshot save/load CPU-trap sequence diagram](diagrams/snapshot-save-load-flow.png)
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
```
+222
View File
@@ -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.
+16
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

+11
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+16
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+15
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+21
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+15
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+5
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+236
View File
@@ -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