Add in-app file import and episode picker

Assets (ROMs and disk images) now live in getExternalFilesDir, making
  them accessible via USB file transfer and any file manager app without
  adb or root.

  - "Import ROMs" button walks through kernal/basic/chargen picks in
    sequence and restarts the emulator when done
  - "Import Disks" button opens a multi-select picker; files are saved
    as SCHWUM<N><P>.D64 (uppercased for consistent lookup)
  - 2×4 episode grid (1A–4B) lights up each button as its disk image
    becomes available; tapping loads that disk into drive 8
This commit is contained in:
ml
2026-06-05 09:44:06 +02:00
parent 8c7f2e3306
commit 678108530c
4 changed files with 253 additions and 21 deletions
+15 -12
View File
@@ -101,17 +101,20 @@ 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** must be pushed to the device separately (not redistributable). **C64 ROM files** must be copied to the device separately (not redistributable).
VICE expects files named exactly `kernal`, `basic`, `chargen` (no extensions) in the VICE expects files named exactly `kernal`, `basic`, `chargen` (no extensions).
app's private `filesDir`: `/data/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/`
**Via USB file transfer (recommended):** connect the phone, open it in Windows Explorer or
Android File Transfer (Mac), and copy the files to:
```
Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/
```
**Via adb (alternative):**
```bash ```bash
adb push kernal /data/local/tmp/kernal adb push kernal /sdcard/Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/kernal
adb push basic /data/local/tmp/basic adb push basic /sdcard/Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/basic
adb push chargen /data/local/tmp/chargen adb push chargen /sdcard/Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/chargen
adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/kernal files/kernal
adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/basic files/basic
adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tmp/chargen files/chargen
``` ```
### Key Kotlin/C files ### Key Kotlin/C files
@@ -127,8 +130,8 @@ adb shell run-as de.ladkau.schwertundmagieonpebblecompanionapp cp /data/local/tm
### Disk images ### Disk images
The four original `.d64` disk images are in `versions/`. Load via: The four original `.d64` disk images are in `versions/`. Copy the relevant image to the
same external files directory as the ROMs (via USB or adb), then load via:
```kotlin ```kotlin
engine.loadDisk(filesDir.absolutePath + "/schwert_und_magie_1.d64") engine.loadDisk(getExternalFilesDir(null)!!.absolutePath + "/schwert_und_magie_1.d64")
``` ```
(Copy the relevant `.d64` to `filesDir` first via `adb push`.)
@@ -9,8 +9,10 @@ import java.nio.ByteBuffer
* VICE source must be placed at app/src/main/jni/vice-src/ before building * VICE source must be placed at app/src/main/jni/vice-src/ before building
* (download from https://vice-emu.sourceforge.io/). * (download from https://vice-emu.sourceforge.io/).
* *
* ROM files (kernal, basic, chargen) must be copied to the app's filesDir * ROM files (kernal, basic, chargen) and .d64 disk images must be copied to
* before calling initEmulator(). * the app's external files directory before calling initEmulator().
* That directory is accessible via USB file transfer (no adb needed) at:
* Android/data/de.ladkau.schwertundmagieonpebblecompanionapp/files/
*/ */
class C64Engine { class C64Engine {
@@ -1,13 +1,22 @@
package de.ladkau.schwertundmagieonpebblecompanionapp package de.ladkau.schwertundmagieonpebblecompanionapp
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.widget.Button
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoHTTPD
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -15,6 +24,10 @@ import java.util.Locale
private const val PORT = 8888 private const val PORT = 8888
private const val TAG = "SuM" private const val TAG = "SuM"
private data class DiskSlot(val episode: Int, val part: Char, val btnId: Int) {
val fileName: String get() = "SCHWUM${episode}${part}.D64"
}
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var tvStatus: TextView private lateinit var tvStatus: TextView
@@ -32,6 +45,66 @@ class MainActivity : AppCompatActivity() {
private var emuThread: Thread? = null private var emuThread: Thread? = null
@Volatile private var emuRunning = false @Volatile private var emuRunning = false
// ---- disk slots ---------------------------------------------------------
private val diskSlots = listOf(
DiskSlot(1, 'A', R.id.btn_ep_1a), DiskSlot(1, 'B', R.id.btn_ep_1b),
DiskSlot(2, 'A', R.id.btn_ep_2a), DiskSlot(2, 'B', R.id.btn_ep_2b),
DiskSlot(3, 'A', R.id.btn_ep_3a), DiskSlot(3, 'B', R.id.btn_ep_3b),
DiskSlot(4, 'A', R.id.btn_ep_4a), DiskSlot(4, 'B', R.id.btn_ep_4b),
)
private val episodeButtons = mutableMapOf<DiskSlot, Button>()
// ---- ROM import (sequential: kernal → basic → chargen) -----------------
private val romNames = listOf("kernal", "basic", "chargen")
private var romImportIndex = 0
private val romPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val target = romNames[romImportIndex]
importFileToAssets(uri, target)
appendLog("Imported: $target")
romImportIndex++
if (romImportIndex < romNames.size) {
launchPicker(romPickerLauncher, "Select '${romNames[romImportIndex]}' ROM file")
} else {
romImportIndex = 0
Toast.makeText(this, "ROMs imported — restarting emulator", Toast.LENGTH_SHORT).show()
recreate()
}
} ?: run { romImportIndex = 0 }
} else {
romImportIndex = 0
}
}
// ---- Disk import (multi-select) -----------------------------------------
private val diskPickerLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
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) }
var count = 0
for (uri in uris) {
val name = displayNameForUri(uri) ?: continue
importFileToAssets(uri, name.uppercase())
appendLog("Imported: $name")
count++
}
if (count > 0) refreshDiskButtons()
appendLog("Imported $count disk image(s)")
}
}
// ---- lifecycle ----------------------------------------------------------
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@@ -43,17 +116,35 @@ class MainActivity : AppCompatActivity() {
display = findViewById(R.id.c64_display) display = findViewById(R.id.c64_display)
keyboard = findViewById(R.id.c64_keyboard) keyboard = findViewById(R.id.c64_keyboard)
findViewById<Button>(R.id.btn_import_roms).setOnClickListener {
romImportIndex = 0
launchPicker(romPickerLauncher, "Select 'kernal' ROM file")
}
findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
launchPicker(diskPickerLauncher, "Select SCHWUM disk images", multiSelect = true)
}
for (slot in diskSlots) {
val btn = findViewById<Button>(slot.btnId)
episodeButtons[slot] = btn
btn.setOnClickListener { loadDiskSlot(slot) }
}
initEmulator() initEmulator()
refreshDiskButtons()
startHttpServer() startHttpServer()
wireKeyboard() wireKeyboard()
} }
// ---- emulator ------------------------------------------------------- // ---- emulator -----------------------------------------------------------
private fun initEmulator() { private fun initEmulator() {
val romDir = filesDir.absolutePath val assetDir = getExternalFilesDir(null) ?: filesDir
assetDir.mkdirs()
val romDir = assetDir.absolutePath
val ok = engine.initEmulator(romDir) val ok = engine.initEmulator(romDir)
tvStatus.text = if (ok) "Emulator ready" else "Emulator: no ROMs (stub mode)" tvStatus.text = if (ok) "Emulator ready"
else "Copy ROMs + .d64 to:\n$romDir"
Log.d(TAG, "initEmulator=$ok romDir=$romDir") Log.d(TAG, "initEmulator=$ok romDir=$romDir")
emuRunning = true emuRunning = true
@@ -63,13 +154,12 @@ class MainActivity : AppCompatActivity() {
engine.runFrame() engine.runFrame()
display.updateFrame(engine) display.updateFrame(engine)
val elapsed = System.currentTimeMillis() - t0 val elapsed = System.currentTimeMillis() - t0
// Target ~50 fps (20 ms/frame)
if (elapsed < 20) Thread.sleep(20 - elapsed) if (elapsed < 20) Thread.sleep(20 - elapsed)
} }
}.also { it.name = "emu-loop"; it.isDaemon = true; it.start() } }.also { it.name = "emu-loop"; it.isDaemon = true; it.start() }
} }
// ---- virtual keyboard ----------------------------------------------- // ---- virtual keyboard ---------------------------------------------------
private fun wireKeyboard() { private fun wireKeyboard() {
keyboard.onKeyEvent = { key, pressed -> keyboard.onKeyEvent = { key, pressed ->
@@ -81,7 +171,7 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---- HTTP server (watch bridge) ------------------------------------- // ---- HTTP server (watch bridge) -----------------------------------------
private fun startHttpServer() { private fun startHttpServer() {
server = CompanionServer(PORT, timeFormat, server = CompanionServer(PORT, timeFormat,
@@ -101,7 +191,58 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---- helpers -------------------------------------------------------- // ---- disk helpers -------------------------------------------------------
private fun diskFile(slot: DiskSlot): File? {
val assetDir = getExternalFilesDir(null) ?: filesDir
return assetDir.listFiles()?.firstOrNull { it.name.uppercase() == slot.fileName }
}
private fun refreshDiskButtons() {
for (slot in diskSlots) {
episodeButtons[slot]?.isEnabled = diskFile(slot) != null
}
}
private fun loadDiskSlot(slot: DiskSlot) {
val file = diskFile(slot) ?: run {
appendLog("Disk not found: ${slot.fileName}")
return
}
val ok = engine.loadDisk(file.absolutePath)
appendLog(if (ok) "Loaded: ${slot.fileName}" else "Load failed: ${slot.fileName}")
}
// ---- file import helpers ------------------------------------------------
private fun launchPicker(
launcher: ActivityResultLauncher<Intent>,
title: String,
multiSelect: Boolean = false
) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
if (multiSelect) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
launcher.launch(Intent.createChooser(intent, title))
}
private fun importFileToAssets(uri: Uri, fileName: String): File {
val assetDir = getExternalFilesDir(null) ?: filesDir
val dest = File(assetDir, fileName)
contentResolver.openInputStream(uri)?.use { input ->
dest.outputStream().use { output -> input.copyTo(output) }
Log.d(TAG, "Imported $uri${dest.absolutePath}")
} ?: appendLog("Could not open: $uri")
return dest
}
private fun displayNameForUri(uri: Uri): String? =
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
// ---- helpers ------------------------------------------------------------
private fun appendLog(entry: String) { private fun appendLog(entry: String) {
val ts = timeFormat.format(Date()) val ts = timeFormat.format(Date())
@@ -30,12 +30,98 @@
android:textSize="12sp" /> android:textSize="12sp" />
</LinearLayout> </LinearLayout>
<!-- Import buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<Button
android:id="@+id/btn_import_roms"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginEnd="2dp"
android:text="Import ROMs"
android:textSize="11sp"
style="?attr/materialButtonOutlinedStyle" />
<Button
android:id="@+id/btn_import_disks"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:layout_marginStart="2dp"
android:text="Import Disks"
android:textSize="11sp"
style="?attr/materialButtonOutlinedStyle" />
</LinearLayout>
<!-- Episode selector — row A (parts 1A4A), row B (parts 1B4B) -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Load episode:"
android:textColor="#888888"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button android:id="@+id/btn_ep_1a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginEnd="1dp" android:text="1A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_2a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="2A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_3a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="3A" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_4a" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:text="4A" android:textSize="11sp"
android:enabled="false" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<Button android:id="@+id/btn_ep_1b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginEnd="1dp" android:text="1B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_2b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="2B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_3b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:text="3B" android:textSize="11sp"
android:enabled="false" />
<Button android:id="@+id/btn_ep_4b" style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp" android:layout_height="36dp" android:layout_weight="1"
android:layout_marginStart="1dp" android:text="4B" android:textSize="11sp"
android:enabled="false" />
</LinearLayout>
<!-- C64 display (320×200, scales to fill width, capped at 40 % of screen height) --> <!-- C64 display (320×200, scales to fill width, capped at 40 % of screen height) -->
<de.ladkau.schwertundmagieonpebblecompanionapp.C64DisplayView <de.ladkau.schwertundmagieonpebblecompanionapp.C64DisplayView
android:id="@+id/c64_display" android:id="@+id/c64_display"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="3" android:layout_weight="3"
android:layout_marginTop="4dp"
android:background="#000044" /> android:background="#000044" />
<!-- Virtual keyboard --> <!-- Virtual keyboard -->