Adding a save state panel

This commit is contained in:
ml
2026-06-14 10:04:59 +02:00
parent 755e7758e4
commit 0f814eb134
7 changed files with 221 additions and 9 deletions
@@ -28,6 +28,12 @@ class C64Engine {
/** Eject the disk and hard-reset the C64 to a BASIC ready prompt. */ /** Eject the disk and hard-reset the C64 to a BASIC ready prompt. */
external fun resetMachine(): Boolean external fun resetMachine(): Boolean
/** Save a full machine snapshot to [path] (queued to the VICE thread). */
external fun saveState(path: String): Boolean
/** Restore a machine snapshot from [path] (queued to the VICE thread). */
external fun loadState(path: String): Boolean
/** Run the emulator for one video frame (~20 000 CPU cycles). */ /** Run the emulator for one video frame (~20 000 CPU cycles). */
external fun runFrame() external fun runFrame()
@@ -170,6 +170,7 @@ class MainActivity : AppCompatActivity() {
} }
findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() } findViewById<Button>(R.id.btn_help).setOnClickListener { showHelp() }
findViewById<Button>(R.id.btn_savestate).setOnClickListener { showSaveStatePanel() }
findViewById<Button>(R.id.btn_import_disks).setOnClickListener { findViewById<Button>(R.id.btn_import_disks).setOnClickListener {
launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true) launchPicker(diskPickerLauncher, getString(R.string.picker_title), multiSelect = true)
@@ -660,6 +661,112 @@ class MainActivity : AppCompatActivity() {
// ---- helpers ------------------------------------------------------------ // ---- helpers ------------------------------------------------------------
// ---- save state panel ---------------------------------------------------
private fun stateFile(slot: Int): File {
val dir = getExternalFilesDir(null) ?: filesDir
return File(dir, "SAVESTATE_$slot.VSF")
}
private fun showSaveStatePanel() {
val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() }
val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
val content = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(dp(16), dp(12), dp(16), dp(8))
setBackgroundColor(Color.parseColor("#1A1A2E"))
}
// Per-slot views that need refreshing after a Save
val timeTvs = arrayOfNulls<TextView>(5)
val loadBtns = arrayOfNulls<Button>(5)
var dialog: AlertDialog? = null
for (slot in 1..5) {
val file = stateFile(slot)
val idx = slot - 1
val row = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = dp(6) }
}
row.addView(TextView(this).apply {
text = getString(R.string.savestate_slot, slot)
textSize = 12f
setTextColor(Color.parseColor("#AAFFAA"))
layoutParams = android.widget.LinearLayout.LayoutParams(dp(52),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)
})
val timeTv = TextView(this).apply {
val exists = file.exists()
text = if (exists) dateFmt.format(Date(file.lastModified()))
else getString(R.string.savestate_empty)
textSize = 11f
setTextColor(if (exists) Color.parseColor("#AAAAAA") else Color.parseColor("#444444"))
layoutParams = android.widget.LinearLayout.LayoutParams(0,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
timeTvs[idx] = timeTv
row.addView(timeTv)
val loadBtn = Button(this).apply {
text = getString(R.string.savestate_load)
textSize = 11f
isEnabled = file.exists()
layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = dp(4)
}
setOnClickListener {
engine.loadState(file.absolutePath)
appendLog("State loaded: slot $slot")
dialog?.dismiss()
}
}
loadBtns[idx] = loadBtn
row.addView(loadBtn)
row.addView(Button(this).apply {
text = getString(R.string.savestate_save)
textSize = 11f
layoutParams = android.widget.LinearLayout.LayoutParams(dp(80),
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = dp(4)
}
setOnClickListener {
engine.saveState(file.absolutePath)
appendLog("State saved: slot $slot")
// VICE writes asynchronously; refresh the row after one second
mainHandler.postDelayed({
if (file.exists()) {
timeTvs[idx]?.text = dateFmt.format(Date(file.lastModified()))
timeTvs[idx]?.setTextColor(Color.parseColor("#AAAAAA"))
loadBtns[idx]?.isEnabled = true
}
}, 1000)
}
})
content.addView(row)
}
dialog = AlertDialog.Builder(this)
.setTitle(R.string.savestate_title)
.setView(android.widget.ScrollView(this).apply {
addView(content)
setBackgroundColor(Color.parseColor("#1A1A2E"))
})
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showHelp() { private fun showHelp() {
fun load(german: Boolean) = try { fun load(german: Boolean) = try {
assets.open(if (german) "help_de.md" else "help_en.md").bufferedReader().readText() assets.open(if (german) "help_de.md" else "help_en.md").bufferedReader().readText()
@@ -30,7 +30,9 @@ if(HAVE_VICE)
-Wl,--wrap=machine_init -Wl,--wrap=machine_init
-Wl,--wrap=console_init -Wl,--wrap=console_init
-Wl,--wrap=ui_display_drive_led -Wl,--wrap=ui_display_drive_led
-Wl,--wrap=serial_trap_receive) -Wl,--wrap=serial_trap_receive
-Wl,--wrap=event_snapshot_write_module
-Wl,--wrap=event_snapshot_read_module)
target_include_directories(vice_jni PRIVATE target_include_directories(vice_jni PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}" "${CMAKE_CURRENT_SOURCE_DIR}/vice-libs/${ANDROID_ABI}"
"${VICE_SRC}/src" "${VICE_SRC}/src"
@@ -36,16 +36,20 @@ static uint32_t g_framebuf[FRAME_W * FRAME_H];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_ready = 0; static int g_ready = 0;
/* Pending disk/reset operations — written by Android thread, consumed by /* Pending disk/reset/snapshot operations — written by Android thread, consumed by
* video_canvas_refresh on the VICE thread (machine_trigger_reset and disk APIs * video_canvas_refresh on the VICE thread (machine_trigger_reset, disk APIs, and
* must be called from the VICE thread to avoid corrupting internal state). * snapshot APIs must be called from the VICE thread to avoid corrupting state).
* g_pending_disk → autostart_disk (resets the machine, used for A-side disks) * g_pending_disk → autostart_disk (resets machine, used for A-side disks)
* g_pending_attach → file_system_attach_disk (hot-swap, used for B-side / hero disks) * g_pending_attach → file_system_attach_disk (hot-swap, B-side / hero disks)
* g_pending_reset → detach disk + hard reset to BASIC prompt */ * g_pending_reset → detach disk + hard reset to BASIC prompt
* g_pending_save_state → machine_write_snapshot (save full machine state)
* g_pending_load_state → machine_read_snapshot (restore full machine state) */
static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_pending_lock = PTHREAD_MUTEX_INITIALIZER;
static char g_pending_disk[512]; static char g_pending_disk[512];
static char g_pending_attach[512]; static char g_pending_attach[512];
static volatile int g_pending_reset = 0; static volatile int g_pending_reset = 0;
static char g_pending_save_state[512];
static char g_pending_load_state[512];
/* ========================================================================= /* =========================================================================
* VICE integration (compiled only when libvice.a is linked in) * VICE integration (compiled only when libvice.a is linked in)
@@ -278,6 +282,20 @@ static const sound_device_t g_android_sound_device = {
/* Stubs for symbols that live in arch-specific or excluded files. */ /* Stubs for symbols that live in arch-specific or excluded files. */
void main_exit(void) {} void main_exit(void) {}
/* event_snapshot_write/read_module: VICE's event-recording system allocates its
* history buffer (start_snapshot.list.base) only when recording is explicitly
* started via event_record_start(). In our headless build recording is never
* started, so the buffer stays NULL and the real implementations crash with a
* null-pointer dereference when called from machine_write_snapshot.
* Replace them with no-ops: snapshots work fine without event playback data. */
struct snapshot_s;
int __wrap_event_snapshot_write_module(struct snapshot_s *s, int event_mode) {
(void)s; (void)event_mode; return 0;
}
int __wrap_event_snapshot_read_module(struct snapshot_s *s) {
(void)s; return 0;
}
/* All functions below replace arch/headless/video.c (excluded from libvice.a). */ /* All functions below replace arch/headless/video.c (excluded from libvice.a). */
int video_arch_get_active_chip(void) { return 0; /* VIDEO_CHIP_VICII */ } int video_arch_get_active_chip(void) { return 0; /* VIDEO_CHIP_VICII */ }
@@ -384,6 +402,28 @@ void video_canvas_refresh(video_canvas_t *canvas,
} }
} }
/* Snapshot save / load. */
pthread_mutex_lock(&g_pending_lock);
char save_state_path[512] = {0};
char load_state_path[512] = {0};
if (g_pending_save_state[0] != '\0') {
strncpy(save_state_path, g_pending_save_state, sizeof(save_state_path) - 1);
g_pending_save_state[0] = '\0';
}
if (g_pending_load_state[0] != '\0') {
strncpy(load_state_path, g_pending_load_state, sizeof(load_state_path) - 1);
g_pending_load_state[0] = '\0';
}
pthread_mutex_unlock(&g_pending_lock);
if (save_state_path[0] != '\0') {
int r = machine_write_snapshot(save_state_path, 0, 1, 1);
LOGI("machine_write_snapshot → %d", r);
}
if (load_state_path[0] != '\0') {
int r = machine_read_snapshot(load_state_path, 0);
LOGI("machine_read_snapshot → %d", r);
}
/* Drive LED. /* Drive LED.
* VICE's virtual device (vdrive) loads far faster than a real 1541, so * VICE's virtual device (vdrive) loads far faster than a real 1541, so
* autostart_in_progress() is false long before the GDG intro finishes. * autostart_in_progress() is false long before the GDG intro finishes.
@@ -717,3 +757,35 @@ JNI_FN(void, setSoundEnabled)(JNIEnv *env, jobject obj, jboolean enabled) {
(void)env; (void)obj; (void)env; (void)obj;
g_sound_enabled = enabled ? 1 : 0; g_sound_enabled = enabled ? 1 : 0;
} }
JNI_FN(jboolean, saveState)(JNIEnv *env, jobject obj, jstring jpath) {
(void)obj;
#ifdef HAVE_VICE_SRC
const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
pthread_mutex_lock(&g_pending_lock);
strncpy(g_pending_save_state, p, sizeof(g_pending_save_state) - 1);
g_pending_save_state[sizeof(g_pending_save_state) - 1] = '\0';
pthread_mutex_unlock(&g_pending_lock);
(*env)->ReleaseStringUTFChars(env, jpath, p);
return JNI_TRUE;
#else
(void)env; (void)jpath;
return JNI_FALSE;
#endif
}
JNI_FN(jboolean, loadState)(JNIEnv *env, jobject obj, jstring jpath) {
(void)obj;
#ifdef HAVE_VICE_SRC
const char *p = (*env)->GetStringUTFChars(env, jpath, NULL);
pthread_mutex_lock(&g_pending_lock);
strncpy(g_pending_load_state, p, sizeof(g_pending_load_state) - 1);
g_pending_load_state[sizeof(g_pending_load_state) - 1] = '\0';
pthread_mutex_unlock(&g_pending_lock);
(*env)->ReleaseStringUTFChars(env, jpath, p);
return JNI_TRUE;
#else
(void)env; (void)jpath;
return JNI_FALSE;
#endif
}
@@ -51,6 +51,17 @@
android:layout_height="12dp" android:layout_height="12dp"
android:layout_marginEnd="4dp" /> android:layout_marginEnd="4dp" />
<Button
android:id="@+id/btn_savestate"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="4dp"
android:minWidth="0dp"
android:padding="0dp"
android:text="💾"
android:textSize="16sp" />
<Button <Button
android:id="@+id/btn_help" android:id="@+id/btn_help"
style="?attr/materialButtonOutlinedStyle" style="?attr/materialButtonOutlinedStyle"
@@ -30,6 +30,13 @@
<string name="hero_disk_created">Erstellt: %1$s</string> <string name="hero_disk_created">Erstellt: %1$s</string>
<string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string> <string name="hero_disk_create_failed">Fehler beim Erstellen: %1$s</string>
<!-- Save state panel -->
<string name="savestate_title">Spielstände</string>
<string name="savestate_slot">Slot %1$d</string>
<string name="savestate_empty">leer</string>
<string name="savestate_save">Speichern</string>
<string name="savestate_load">Laden</string>
<!-- Hero save editor --> <!-- Hero save editor -->
<string name="editor_title">%1$s bearbeiten</string> <string name="editor_title">%1$s bearbeiten</string>
<string name="editor_save">Speichern</string> <string name="editor_save">Speichern</string>
@@ -32,6 +32,13 @@
<string name="hero_disk_created">Created: %1$s</string> <string name="hero_disk_created">Created: %1$s</string>
<string name="hero_disk_create_failed">Failed to create: %1$s</string> <string name="hero_disk_create_failed">Failed to create: %1$s</string>
<!-- Save state panel -->
<string name="savestate_title">Save States</string>
<string name="savestate_slot">Slot %1$d</string>
<string name="savestate_empty">empty</string>
<string name="savestate_save">Save</string>
<string name="savestate_load">Load</string>
<!-- Hero save editor --> <!-- Hero save editor -->
<string name="editor_title">Edit %1$s</string> <string name="editor_title">Edit %1$s</string>
<string name="editor_save">Save</string> <string name="editor_save">Save</string>