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
|
||||
|
||||
/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).
|
||||
|
||||
**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,7 +1,8 @@
|
||||
/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
|
||||
/src/main/assets/1541
|
||||
/src/main/assets/1541
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
```
|
||||

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

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

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

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

|
||||
|
||||
### 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
|
||||
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
|
||||
```
|
||||
@@ -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
|
||||