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
+6
View File
@@ -22,3 +22,9 @@ local.properties
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
/SchwertUndMagieOnPebbleWatchApp/build
# Release signing — never commit keystores or their credentials.
# See docs/publish.md.
*.jks
*.keystore
keystore.properties
+7 -3
View File
@@ -101,9 +101,13 @@ The NDK must be installed: **Android Studio → SDK Manager → SDK Tools → ND
After the first successful build, subsequent builds skip the VICE compilation step entirely (outputs are up-to-date).
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) all ship inside the VICE 3.8
tarball at `res/vice-3.8.tar.gz` and are bundled in the APK under `app/src/main/assets/`.
They are copied automatically to the external files dir on first launch — no user action needed.
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) are copyrighted and are
**not** bundled in the APK. On first launch, `MainActivity` checks the app's
external files dir for these four files; if any are missing, it shows a blocking
dialog that lets the user import their own legally-obtained ROM dump via the
system file picker (see `docs/publish.md` §0). `res/extract_roms.sh` can pull
the ROMs out of `res/vice-3.8.tar.gz` into `res/roms/` for local sideloading
during development, but nothing in the Gradle build copies them into the APK.
### Key Kotlin/C files
@@ -13,3 +13,6 @@
.externalNativeBuild
.cxx
local.properties
# ROM files extracted from the VICE tarball by res/extract_roms.sh
/res/roms
@@ -1,6 +1,7 @@
/build
# ROM files extracted from the VICE tarball at build time (see extractViceRoms task)
# Copyrighted Commodore ROMs are never bundled — users import their own at
# runtime (MainActivity.showRomImportDialog). Ignored here as a safety net.
/src/main/assets/kernal
/src/main/assets/basic
/src/main/assets/chargen
@@ -130,47 +130,12 @@ tasks.whenTaskAdded {
}
}
// ---------------------------------------------------------------------------
// ROM extraction task
//
// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android
// assets directory so they are packaged into the APK automatically.
// Output files are gitignored — the tarball is the single source of truth.
// ---------------------------------------------------------------------------
val assetsDir = layout.projectDirectory.dir("src/main/assets")
tasks.register<Copy>("extractViceRoms") {
group = "build"
description = "Extract C64 and 1541 ROMs from the VICE tarball into assets/"
from(tarTree(viceTarball.asFile)) {
include("vice-3.8/data/C64/kernal-901227-03.bin")
include("vice-3.8/data/C64/basic-901226-01.bin")
include("vice-3.8/data/C64/chargen-901225-01.bin")
include("vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin")
eachFile {
// Flatten the tarball directory structure: place each ROM directly
// in assets/ with its short name rather than the versioned filename.
relativePath = RelativePath(true, when (name) {
"kernal-901227-03.bin" -> "kernal"
"basic-901226-01.bin" -> "basic"
"chargen-901225-01.bin" -> "chargen"
"dos1541-325302-01+901229-05.bin" -> "1541"
else -> name
})
}
includeEmptyDirs = false
}
into(assetsDir)
}
// Run extractViceRoms before assets are merged into the APK.
tasks.whenTaskAdded {
if (name.startsWith("merge") && name.endsWith("Assets")) {
dependsOn("extractViceRoms")
}
}
// Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are
// copyrighted Commodore firmware. The app prompts the user to import their
// own ROM dump at runtime instead (see MainActivity.showRomImportDialog()).
// res/extract_roms.sh remains available for extracting ROMs from the VICE
// tarball locally (e.g. to sideload onto a test device), but nothing in this
// build copies them into assets/ or the APK.
dependencies {
implementation(libs.androidx.activity.ktx)
@@ -31,9 +31,15 @@ import androidx.drawerlayout.widget.DrawerLayout
import fi.iki.elonen.NanoHTTPD
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.GZIPInputStream
private const val PORT = 8888
private const val TAG = "SuM"
@@ -235,7 +241,14 @@ class MainActivity : AppCompatActivity() {
private fun initEmulator() {
val assetDir = getExternalFilesDir(null) ?: filesDir
assetDir.mkdirs()
copyBundledRoms(assetDir)
if (missingRoms(assetDir).isNotEmpty()) {
showRomImportDialog(assetDir)
return
}
startEmulator(assetDir)
}
private fun startEmulator(assetDir: File) {
val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir)
Log.d(TAG, "initEmulator=$ok romDir=$romDir")
@@ -262,22 +275,213 @@ class MainActivity : AppCompatActivity() {
})
}
// ---- bundled ROM extraction ---------------------------------------------
// ---- ROM import -----------------------------------------------------
private val bundledRoms = listOf("kernal", "basic", "chargen", "1541")
// The companion app does not ship Commodore ROMs (they're copyrighted) —
// the user must supply their own dump on first run. See docs/publish.md §0.
private val requiredRoms = listOf("kernal", "basic", "chargen", "1541")
private fun copyBundledRoms(destDir: File) {
for (name in bundledRoms) {
val dest = File(destDir, name)
if (dest.exists()) continue
try {
assets.open(name).use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
private fun missingRoms(dir: File): List<String> =
requiredRoms.filter { !File(dir, it).exists() }
// Identifies which canonical ROM name a picked file corresponds to, based
// on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as
// well as plain names a user might have renamed a dump to.
private fun romNameForFile(fileName: String): String? {
val lower = fileName.lowercase()
return when {
"kernal" in lower -> "kernal"
"chargen" in lower -> "chargen"
"basic" in lower -> "basic"
"1541" in lower -> "1541"
else -> null
}
}
private val romPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val assetDir = getExternalFilesDir(null) ?: filesDir
if (result.resultCode == Activity.RESULT_OK) {
val uris = mutableListOf<Uri>()
result.data?.clipData?.let { clip ->
for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri)
} ?: result.data?.data?.let { uris.add(it) }
for (uri in uris) {
val name = displayNameForUri(uri) ?: continue
val romName = romNameForFile(name) ?: continue
contentResolver.openInputStream(uri)?.use { input ->
File(assetDir, romName).outputStream().use { output -> input.copyTo(output) }
}
appendLog("Imported ROM: $romName")
}
Log.d(TAG, "Copied bundled ROM: $name")
} catch (e: Exception) {
Log.e(TAG, "Failed to copy bundled ROM $name", e)
}
val stillMissing = missingRoms(assetDir)
if (stillMissing.isEmpty()) {
startEmulator(assetDir)
} else {
Toast.makeText(
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
Toast.LENGTH_LONG
).show()
showRomImportDialog(assetDir)
}
}
private fun showRomImportDialog(dir: File) {
AlertDialog.Builder(this)
.setTitle(R.string.roms_missing_title)
.setMessage(getString(R.string.roms_missing_msg, missingRoms(dir).joinToString()))
.setCancelable(false)
.setPositiveButton(R.string.roms_import_button) { _, _ ->
launchPicker(romPickerLauncher, getString(R.string.rom_picker_title), multiSelect = true)
}
.setNeutralButton(R.string.roms_download_button) { _, _ -> downloadRoms(dir) }
.show()
}
// The same VICE 3.8 source tarball already bundled at res/vice-3.8.tar.gz
// (used to cross-compile VICE itself), fetched from VICE's own official
// GitHub release rather than shipped in the APK. The four ROMs are
// extracted from it client-side — see docs/publish.md §0 for why this is
// not equivalent to bundling them in the app.
private val viceTarballUrl =
"https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz"
private fun downloadRoms(destDir: File) {
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val progressBar = android.widget.ProgressBar(
this, null, android.R.attr.progressBarStyleHorizontal
).apply { isIndeterminate = true }
val statusTv = TextView(this).apply {
text = getString(R.string.roms_downloading)
textSize = 12f
setTextColor(Color.parseColor("#CCCCCC"))
setPadding(0, dp(8), 0, 0)
}
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
addView(progressBar)
addView(statusTv)
}
val progressDialog = AlertDialog.Builder(this)
.setTitle(R.string.roms_download_button)
.setView(content)
.setCancelable(false)
.show()
Thread {
try {
val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 15000
readTimeout = 30000
instanceFollowRedirects = true
}
try {
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${conn.responseCode}")
}
val total = conn.contentLengthLong
var lastPercent = -1
val countingStream = CountingInputStream(conn.inputStream) { downloaded ->
if (total > 0) {
val percent = ((downloaded * 100) / total).toInt()
if (percent != lastPercent) {
lastPercent = percent
mainHandler.post {
progressBar.isIndeterminate = false
progressBar.progress = percent
statusTv.text = getString(R.string.roms_downloading_progress, percent)
}
}
}
}
extractRomsFromTarGz(countingStream, destDir)
} finally {
conn.disconnect()
}
mainHandler.post {
progressDialog.dismiss()
val stillMissing = missingRoms(destDir)
if (stillMissing.isEmpty()) {
appendLog("ROM download complete")
startEmulator(destDir)
} else {
appendLog("ROM download incomplete: ${stillMissing.joinToString()}")
Toast.makeText(
this, getString(R.string.roms_still_missing, stillMissing.joinToString()),
Toast.LENGTH_LONG
).show()
showRomImportDialog(destDir)
}
}
} catch (e: Exception) {
Log.e(TAG, "ROM download failed", e)
mainHandler.post {
progressDialog.dismiss()
appendLog("ROM download failed: ${e.message}")
Toast.makeText(this, R.string.roms_download_failed, Toast.LENGTH_LONG).show()
showRomImportDialog(destDir)
}
}
}.start()
}
// Minimal USTAR reader — pulls just the four named ROM entries out of the
// gzipped VICE source tarball without needing a tar library dependency.
private fun extractRomsFromTarGz(rawInput: InputStream, destDir: File) {
val wantedEntries = mapOf(
"vice-3.8/data/C64/kernal-901227-03.bin" to "kernal",
"vice-3.8/data/C64/basic-901226-01.bin" to "basic",
"vice-3.8/data/C64/chargen-901225-01.bin" to "chargen",
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" to "1541",
)
GZIPInputStream(rawInput).use { gz ->
val header = ByteArray(512)
while (true) {
var read = 0
while (read < 512) {
val n = gz.read(header, read, 512 - read)
if (n < 0) return
read += n
}
val name = String(header, 0, 100, Charsets.US_ASCII).trimEnd('\u0000', ' ')
val sizeField = String(header, 124, 12, Charsets.US_ASCII).trim('\u0000', ' ')
val size = if (sizeField.isEmpty()) 0L else sizeField.toLong(8)
val destName = wantedEntries[name]
if (destName != null) {
File(destDir, destName).outputStream().use { out -> copyExactly(gz, out, size) }
} else {
skipExactly(gz, size)
}
val remainder = size % 512
if (remainder != 0L) skipExactly(gz, 512 - remainder)
}
}
}
private fun copyExactly(input: InputStream, output: OutputStream, size: Long) {
val buf = ByteArray(8192)
var remaining = size
while (remaining > 0) {
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
if (n < 0) break
output.write(buf, 0, n)
remaining -= n
}
}
private fun skipExactly(input: InputStream, size: Long) {
val buf = ByteArray(8192)
var remaining = size
while (remaining > 0) {
val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
if (n < 0) break
remaining -= n
}
}
@@ -890,3 +1094,25 @@ class CompanionServer(
/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray)
/** Reports cumulative bytes read via [onProgress] as the wrapped stream is consumed. */
private class CountingInputStream(
private val wrapped: InputStream,
private val onProgress: (Long) -> Unit
) : InputStream() {
private var count = 0L
override fun read(): Int {
val b = wrapped.read()
if (b >= 0) { count++; onProgress(count) }
return b
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
val n = wrapped.read(b, off, len)
if (n > 0) { count += n; onProgress(count) }
return n
}
override fun close() = wrapped.close()
}
@@ -17,6 +17,17 @@
<!-- Disk picker -->
<string name="picker_title">SCHWUM-Disketten auswählen</string>
<!-- ROM import -->
<string name="roms_missing_title">C64-ROMs erforderlich</string>
<string name="roms_missing_msg">Diese App enthält keine Commodore-ROM-Dateien (sie sind urheberrechtlich geschützt). Bitte eigenen, legal erworbenen ROM-Dump bereitstellen. Fehlend: %1$s</string>
<string name="roms_import_button">ROMs importieren</string>
<string name="rom_picker_title">kernal-, basic-, chargen-, 1541-ROM-Dateien auswählen</string>
<string name="roms_still_missing">Noch fehlend: %1$s</string>
<string name="roms_download_button">ROMs herunterladen</string>
<string name="roms_download_failed">ROM-Download fehlgeschlagen — Netzwerkverbindung prüfen</string>
<string name="roms_downloading">Wird heruntergeladen…</string>
<string name="roms_downloading_progress">Wird heruntergeladen… %1$d%%</string>
<!-- Load-order warning toast -->
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
@@ -19,6 +19,17 @@
<!-- Disk picker -->
<string name="picker_title">Select SCHWUM disk images</string>
<!-- ROM import -->
<string name="roms_missing_title">C64 ROMs required</string>
<string name="roms_missing_msg">This app does not include Commodore ROM files (they\'re copyrighted). Please supply your own legally-obtained dump. Missing: %1$s</string>
<string name="roms_import_button">Import ROMs</string>
<string name="rom_picker_title">Select kernal, basic, chargen, 1541 ROM files</string>
<string name="roms_still_missing">Still missing: %1$s</string>
<string name="roms_download_button">Download ROMs</string>
<string name="roms_download_failed">ROM download failed — check your network connection</string>
<string name="roms_downloading">Downloading…</string>
<string name="roms_downloading_progress">Downloading… %1$d%%</string>
<!-- Load-order warning toast -->
<string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string>
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Extracts the C64 and 1541 ROMs needed by vice_jni.c from vice-3.8.tar.gz.
#
# This is a manual/standalone equivalent of the `extractViceRoms` Gradle task
# in app/build.gradle.kts — same source paths, same renaming. Useful for
# inspecting the ROMs outside a full Gradle build (e.g. for the "bring your
# own ROM" flow described in docs/publish.md §0).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARBALL="$SCRIPT_DIR/vice-3.8.tar.gz"
DEST="${1:-$SCRIPT_DIR/roms}"
if [[ ! -f "$TARBALL" ]]; then
echo "error: $TARBALL not found" >&2
exit 1
fi
mkdir -p "$DEST"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
tar -xzf "$TARBALL" -C "$WORK_DIR" \
vice-3.8/data/C64/kernal-901227-03.bin \
vice-3.8/data/C64/basic-901226-01.bin \
vice-3.8/data/C64/chargen-901225-01.bin \
"vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin"
cp "$WORK_DIR/vice-3.8/data/C64/kernal-901227-03.bin" "$DEST/kernal"
cp "$WORK_DIR/vice-3.8/data/C64/basic-901226-01.bin" "$DEST/basic"
cp "$WORK_DIR/vice-3.8/data/C64/chargen-901225-01.bin" "$DEST/chargen"
cp "$WORK_DIR/vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" "$DEST/1541"
echo "Extracted kernal, basic, chargen, 1541 to $DEST"
+16 -113
View File
@@ -10,29 +10,7 @@ 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
```
![System overview diagram](diagrams/system-overview.png)
## 2. Pebble watch app
@@ -40,13 +18,7 @@ graph LR
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)
```
![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
@@ -80,24 +52,7 @@ 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
```
![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)`
@@ -134,24 +89,7 @@ graph TD
### 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
```
![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 —
@@ -171,62 +109,18 @@ to the standard conversion:
### 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)
```
![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)
```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
```
![Watch key wheel input sequence diagram](diagrams/watch-wheel-input-flow.png)
### 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
```
![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
@@ -257,3 +151,12 @@ 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