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)
@@ -22,3 +22,9 @@ local.properties
|
|||||||
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
|
/SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs
|
||||||
|
|
||||||
/SchwertUndMagieOnPebbleWatchApp/build
|
/SchwertUndMagieOnPebbleWatchApp/build
|
||||||
|
|
||||||
|
# Release signing — never commit keystores or their credentials.
|
||||||
|
# See docs/publish.md.
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
keystore.properties
|
||||||
|
|||||||
@@ -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).
|
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
|
**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) are copyrighted and are
|
||||||
tarball at `res/vice-3.8.tar.gz` and are bundled in the APK under `app/src/main/assets/`.
|
**not** bundled in the APK. On first launch, `MainActivity` checks the app's
|
||||||
They are copied automatically to the external files dir on first launch — no user action needed.
|
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
|
### Key Kotlin/C files
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,6 @@
|
|||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# ROM files extracted from the VICE tarball by res/extract_roms.sh
|
||||||
|
/res/roms
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/build
|
/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/kernal
|
||||||
/src/main/assets/basic
|
/src/main/assets/basic
|
||||||
/src/main/assets/chargen
|
/src/main/assets/chargen
|
||||||
|
|||||||
@@ -130,47 +130,12 @@ tasks.whenTaskAdded {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are
|
||||||
// ROM extraction task
|
// copyrighted Commodore firmware. The app prompts the user to import their
|
||||||
//
|
// own ROM dump at runtime instead (see MainActivity.showRomImportDialog()).
|
||||||
// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android
|
// res/extract_roms.sh remains available for extracting ROMs from the VICE
|
||||||
// assets directory so they are packaged into the APK automatically.
|
// tarball locally (e.g. to sideload onto a test device), but nothing in this
|
||||||
// Output files are gitignored — the tarball is the single source of truth.
|
// build copies them into assets/ or the APK.
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.activity.ktx)
|
implementation(libs.androidx.activity.ktx)
|
||||||
|
|||||||
@@ -31,9 +31,15 @@ import androidx.drawerlayout.widget.DrawerLayout
|
|||||||
import fi.iki.elonen.NanoHTTPD
|
import fi.iki.elonen.NanoHTTPD
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
private const val PORT = 8888
|
private const val PORT = 8888
|
||||||
private const val TAG = "SuM"
|
private const val TAG = "SuM"
|
||||||
@@ -235,7 +241,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun initEmulator() {
|
private fun initEmulator() {
|
||||||
val assetDir = getExternalFilesDir(null) ?: filesDir
|
val assetDir = getExternalFilesDir(null) ?: filesDir
|
||||||
assetDir.mkdirs()
|
assetDir.mkdirs()
|
||||||
copyBundledRoms(assetDir)
|
if (missingRoms(assetDir).isNotEmpty()) {
|
||||||
|
showRomImportDialog(assetDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startEmulator(assetDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startEmulator(assetDir: File) {
|
||||||
val romDir = assetDir.absolutePath
|
val romDir = assetDir.absolutePath
|
||||||
val ok = engine.initEmulator(romDir)
|
val ok = engine.initEmulator(romDir)
|
||||||
Log.d(TAG, "initEmulator=$ok romDir=$romDir")
|
Log.d(TAG, "initEmulator=$ok romDir=$romDir")
|
||||||
@@ -262,23 +275,214 @@ 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) {
|
private fun missingRoms(dir: File): List<String> =
|
||||||
for (name in bundledRoms) {
|
requiredRoms.filter { !File(dir, it).exists() }
|
||||||
val dest = File(destDir, name)
|
|
||||||
if (dest.exists()) continue
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
assets.open(name).use { input ->
|
val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply {
|
||||||
dest.outputStream().use { output -> input.copyTo(output) }
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Copied bundled ROM: $name")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to copy bundled ROM $name", e)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- virtual keyboard ---------------------------------------------------
|
// ---- virtual keyboard ---------------------------------------------------
|
||||||
@@ -890,3 +1094,25 @@ class CompanionServer(
|
|||||||
|
|
||||||
/** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */
|
/** 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)
|
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 -->
|
<!-- Disk picker -->
|
||||||
<string name="picker_title">SCHWUM-Disketten auswählen</string>
|
<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 -->
|
<!-- Load-order warning toast -->
|
||||||
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
|
<string name="load_a_first">%1$s zuerst laden — %2$s setzt dort fort</string>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
<!-- Disk picker -->
|
<!-- Disk picker -->
|
||||||
<string name="picker_title">Select SCHWUM disk images</string>
|
<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 -->
|
<!-- Load-order warning toast -->
|
||||||
<string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string>
|
<string name="load_a_first">Load %1$s first — %2$s continues from where it left off</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
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.
|
to a JS runtime; our companion app talks to it only via loopback HTTP.
|
||||||
|
|
||||||
```mermaid
|

|
||||||
graph LR
|
|
||||||
subgraph Watch["Pebble Time 2 (watch)"]
|
|
||||||
WatchC["Watch app (C)<br/>splash / main / wheel windows"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Phone["Android phone"]
|
|
||||||
CFP["Core for Pebble<br/>(PebbleKit JS runtime, separate app)"]
|
|
||||||
|
|
||||||
subgraph Companion["Companion app process"]
|
|
||||||
HTTP["NanoHTTPD server :8888"]
|
|
||||||
UI["MainActivity / UI<br/>C64DisplayView, C64KeyboardView"]
|
|
||||||
JNI["JNI bridge<br/>vice_jni.c"]
|
|
||||||
VICE["VICE C64 core<br/>(own pthread)"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
WatchC <-->|Bluetooth AppMessage| CFP
|
|
||||||
CFP <-->|HTTP loopback 127.0.0.1:8888| HTTP
|
|
||||||
HTTP --> UI
|
|
||||||
UI --> JNI
|
|
||||||
JNI <--> VICE
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Pebble watch app
|
## 2. Pebble watch app
|
||||||
|
|
||||||
@@ -40,13 +18,7 @@ graph LR
|
|||||||
single-file C watchapp built around three `Window`s on a shared stack, plus
|
single-file C watchapp built around three `Window`s on a shared stack, plus
|
||||||
`src/pkjs/index.js` running inside Core for Pebble.
|
`src/pkjs/index.js` running inside Core for Pebble.
|
||||||
|
|
||||||
```mermaid
|

|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Splash
|
|
||||||
Splash --> Main: 1800ms timer\n(window_stack_remove splash)
|
|
||||||
Main --> Wheel: SELECT\n(push wheel)
|
|
||||||
Wheel --> Main: SELECT (send + pop)\nor BACK (cancel, pop)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Splash** — `BitmapLayer` showing `resources/splash.png`, centered, black
|
- **Splash** — `BitmapLayer` showing `resources/splash.png`, centered, black
|
||||||
backdrop. Pushed *on top of* the already-pushed Main window at startup (not
|
backdrop. Pushed *on top of* the already-pushed Main window at startup (not
|
||||||
@@ -80,24 +52,7 @@ overrides, + 25 newlines).
|
|||||||
|
|
||||||
## 3. Android companion app
|
## 3. Android companion app
|
||||||
|
|
||||||
```mermaid
|

|
||||||
graph TD
|
|
||||||
Main["Main/UI thread<br/>Choreographer vsync loop, touch events"]
|
|
||||||
Vice["VICE thread<br/>main_program() → maincpu_mainloop()"]
|
|
||||||
Http["NanoHTTPD worker thread(s)<br/>one per request"]
|
|
||||||
StdoutT["stdout reader thread"]
|
|
||||||
StderrT["stderr reader thread"]
|
|
||||||
Audio["OpenSL ES callback thread"]
|
|
||||||
|
|
||||||
Main -->|"captureFrame(): reads g_framebuf"| Vice
|
|
||||||
Main -->|"injectKey(): writes keyboard matrix"| Vice
|
|
||||||
Http -->|"onKey → injectWatchKey → injectKey"| Vice
|
|
||||||
Http -->|"getScreenText(): reads C64 RAM"| Vice
|
|
||||||
Vice -->|"writes g_framebuf, drains pending queues"| Main
|
|
||||||
Vice -->|stdout/stderr pipes| StdoutT
|
|
||||||
Vice -->|stdout/stderr pipes| StderrT
|
|
||||||
Vice -->|sound ring buffer| Audio
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Main/UI thread** — `MainActivity`'s `Choreographer.postFrameCallback` loop
|
- **Main/UI thread** — `MainActivity`'s `Choreographer.postFrameCallback` loop
|
||||||
drives rendering: on every hardware vsync it calls `display.captureFrame(engine)`
|
drives rendering: on every hardware vsync it calls `display.captureFrame(engine)`
|
||||||
@@ -134,24 +89,7 @@ graph TD
|
|||||||
|
|
||||||
### 4.1 Screen mirror (VICE → watch)
|
### 4.1 Screen mirror (VICE → watch)
|
||||||
|
|
||||||
```mermaid
|

|
||||||
sequenceDiagram
|
|
||||||
participant VICE as VICE thread
|
|
||||||
participant JNI as vice_jni.c
|
|
||||||
participant HTTP as NanoHTTPD /screen
|
|
||||||
participant JS as PebbleKit JS
|
|
||||||
participant Watch as Watch app (KEY_SCREEN)
|
|
||||||
|
|
||||||
loop every 1s
|
|
||||||
JS->>HTTP: GET /screen
|
|
||||||
HTTP->>JNI: getScreenText()
|
|
||||||
JNI->>VICE: mem_read_screen($0400..$07E7)
|
|
||||||
JNI-->>HTTP: UTF-8 text (40x25, \n per row)
|
|
||||||
HTTP-->>JS: {"text": "..."}
|
|
||||||
JS->>Watch: AppMessage KEY_SCREEN
|
|
||||||
Watch->>Watch: update ScrollLayer/TextLayer
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
`getScreenText()` reads C64 screen RAM at the fixed default address `$0400`
|
`getScreenText()` reads C64 screen RAM at the fixed default address `$0400`
|
||||||
(same assumption `autostart.c` makes when checking for KERNAL "READY." text —
|
(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)
|
### 4.2 On-screen keyboard input (phone touch → VICE)
|
||||||
|
|
||||||
```mermaid
|

|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant KB as C64KeyboardView
|
|
||||||
participant Main as MainActivity
|
|
||||||
participant JNI as vice_jni.c
|
|
||||||
participant VICE as VICE keyboard matrix
|
|
||||||
|
|
||||||
User->>KB: touch down/up on key
|
|
||||||
KB->>Main: onKeyEvent(key, pressed)
|
|
||||||
Main->>JNI: engine.injectKey(code, pressed) (per code, for composites)
|
|
||||||
JNI->>VICE: keyboard_set_keyarr(row, col, pressed)
|
|
||||||
```
|
|
||||||
|
|
||||||
Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are
|
Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are
|
||||||
pressed/released together.
|
pressed/released together.
|
||||||
|
|
||||||
### 4.3 Watch key wheel input (watch → VICE)
|
### 4.3 Watch key wheel input (watch → VICE)
|
||||||
|
|
||||||
```mermaid
|

|
||||||
sequenceDiagram
|
|
||||||
participant Watch
|
|
||||||
participant JS as PebbleKit JS
|
|
||||||
participant HTTP as NanoHTTPD /key
|
|
||||||
participant Main as MainActivity
|
|
||||||
participant JNI as vice_jni.c
|
|
||||||
|
|
||||||
Watch->>Watch: SELECT opens wheel; UP/DOWN rotate; SELECT confirms
|
|
||||||
Watch->>JS: AppMessage KEY_COMMAND = "<label>"
|
|
||||||
JS->>HTTP: GET /key?cmd=<label>
|
|
||||||
HTTP->>Main: onKey(cmd)
|
|
||||||
Main->>Main: watchKeyMap[cmd] → codes
|
|
||||||
Main->>JNI: injectKey(code, true) for each code
|
|
||||||
Main->>Main: postDelayed 80ms
|
|
||||||
Main->>JNI: injectKey(code, false) for each code
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Snapshot save/load (CPU-trap register sync)
|
### 4.4 Snapshot save/load (CPU-trap register sync)
|
||||||
|
|
||||||
```mermaid
|

|
||||||
sequenceDiagram
|
|
||||||
participant UI as MainActivity (save/load button)
|
|
||||||
participant Pending as g_pending_save_state / g_pending_load_state
|
|
||||||
participant Refresh as video_canvas_refresh (VICE thread)
|
|
||||||
participant Trap as interrupt_maincpu_trigger_trap
|
|
||||||
participant CPU as 6510core.c DO_INTERRUPT(IK_TRAP)
|
|
||||||
|
|
||||||
UI->>Pending: saveState(path) / loadState(path) [mutex-guarded]
|
|
||||||
Refresh->>Pending: drain pending path next frame
|
|
||||||
Refresh->>Trap: schedule save_state_trap / load_state_trap
|
|
||||||
CPU->>CPU: EXPORT_REGISTERS()
|
|
||||||
CPU->>Trap: run trap function
|
|
||||||
Trap->>Trap: machine_write_snapshot() / machine_read_snapshot()
|
|
||||||
CPU->>CPU: IMPORT_REGISTERS()
|
|
||||||
Note over CPU: reg_pc now matches the saved/restored maincpu_regs.pc
|
|
||||||
```
|
|
||||||
|
|
||||||
Both save and load **must** run inside a CPU trap. `maincpu_mainloop()` keeps
|
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
|
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
|
input purposes. This is a different category from §4.4: snapshot register
|
||||||
sync is correctness-critical (a wrong PC corrupts execution permanently), so
|
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.
|
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
|
||||||