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:
+4
-2
@@ -9,8 +9,10 @@ import java.nio.ByteBuffer
|
||||
* VICE source must be placed at app/src/main/jni/vice-src/ before building
|
||||
* (download from https://vice-emu.sourceforge.io/).
|
||||
*
|
||||
* ROM files (kernal, basic, chargen) must be copied to the app's filesDir
|
||||
* before calling initEmulator().
|
||||
* ROM files (kernal, basic, chargen) and .d64 disk images must be copied to
|
||||
* 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 {
|
||||
|
||||
|
||||
+148
-7
@@ -1,13 +1,22 @@
|
||||
package de.ladkau.schwertundmagieonpebblecompanionapp
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.ScrollView
|
||||
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 fi.iki.elonen.NanoHTTPD
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -15,6 +24,10 @@ import java.util.Locale
|
||||
private const val PORT = 8888
|
||||
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() {
|
||||
|
||||
private lateinit var tvStatus: TextView
|
||||
@@ -32,6 +45,66 @@ class MainActivity : AppCompatActivity() {
|
||||
private var emuThread: Thread? = null
|
||||
@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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
@@ -43,17 +116,35 @@ class MainActivity : AppCompatActivity() {
|
||||
display = findViewById(R.id.c64_display)
|
||||
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()
|
||||
refreshDiskButtons()
|
||||
startHttpServer()
|
||||
wireKeyboard()
|
||||
}
|
||||
|
||||
// ---- emulator -------------------------------------------------------
|
||||
// ---- emulator -----------------------------------------------------------
|
||||
|
||||
private fun initEmulator() {
|
||||
val romDir = filesDir.absolutePath
|
||||
val assetDir = getExternalFilesDir(null) ?: filesDir
|
||||
assetDir.mkdirs()
|
||||
val romDir = assetDir.absolutePath
|
||||
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")
|
||||
|
||||
emuRunning = true
|
||||
@@ -63,13 +154,12 @@ class MainActivity : AppCompatActivity() {
|
||||
engine.runFrame()
|
||||
display.updateFrame(engine)
|
||||
val elapsed = System.currentTimeMillis() - t0
|
||||
// Target ~50 fps (20 ms/frame)
|
||||
if (elapsed < 20) Thread.sleep(20 - elapsed)
|
||||
}
|
||||
}.also { it.name = "emu-loop"; it.isDaemon = true; it.start() }
|
||||
}
|
||||
|
||||
// ---- virtual keyboard -----------------------------------------------
|
||||
// ---- virtual keyboard ---------------------------------------------------
|
||||
|
||||
private fun wireKeyboard() {
|
||||
keyboard.onKeyEvent = { key, pressed ->
|
||||
@@ -81,7 +171,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP server (watch bridge) -------------------------------------
|
||||
// ---- HTTP server (watch bridge) -----------------------------------------
|
||||
|
||||
private fun startHttpServer() {
|
||||
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) {
|
||||
val ts = timeFormat.format(Date())
|
||||
|
||||
@@ -30,12 +30,98 @@
|
||||
android:textSize="12sp" />
|
||||
</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 1A–4A), row B (parts 1B–4B) -->
|
||||
<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) -->
|
||||
<de.ladkau.schwertundmagieonpebblecompanionapp.C64DisplayView
|
||||
android:id="@+id/c64_display"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="3"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="#000044" />
|
||||
|
||||
<!-- Virtual keyboard -->
|
||||
|
||||
Reference in New Issue
Block a user