added snapshots/save states

This commit is contained in:
izzy2lost
2026-02-28 01:30:08 -05:00
parent 8846a8eac3
commit bca868acb5
9 changed files with 1070 additions and 41 deletions
+3
View File
@@ -1,6 +1,8 @@
#include "qemu/osdep.h"
#include "xui/xemu-hud.h"
extern void xemu_android_process_snapshot_request(void);
void xemu_hud_init(SDL_Window *window, void *sdl_gl_context)
{
(void)window;
@@ -13,6 +15,7 @@ void xemu_hud_cleanup(void)
void xemu_hud_render(void)
{
xemu_android_process_snapshot_request();
}
void xemu_hud_process_sdl_events(SDL_Event *event)
+411 -32
View File
@@ -1,13 +1,292 @@
#include "qemu/osdep.h"
#include "ui/xemu-snapshots.h"
#include "qapi/error.h"
#include "migration/snapshot.h"
#include "migration/qemu-file.h"
#include "system/runstate.h"
#include "xemu-xbe.h"
#include <SDL.h>
#include <SDL_system.h>
#include <GLES3/gl3.h>
#include <android/log.h>
#include <glib/gstdio.h>
#include <jni.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
const char **g_snapshot_shortcut_index_key_map[] = { NULL };
static bool xemu_snapshots_dirty = true;
static GLuint g_snapshot_display_tex = 0;
static bool g_snapshot_display_flip = false;
#define SNAPSHOT_PREVIEW_WIDTH 320
#define SNAPSHOT_PREVIEW_HEIGHT 240
#define SNAPSHOT_PREVIEW_VERSION 1
#define SNAP_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "xemu-android", __VA_ARGS__)
#define SNAP_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "xemu-android", __VA_ARGS__)
typedef struct SnapshotPreviewHeader {
char magic[4];
uint16_t version;
uint16_t width;
uint16_t height;
uint16_t channels;
} SnapshotPreviewHeader;
static void sanitize_snapshot_name(const char *in, char *out, size_t out_len)
{
size_t j = 0;
if (!out || out_len == 0) {
return;
}
if (!in || !in[0]) {
g_strlcpy(out, "snapshot", out_len);
return;
}
for (size_t i = 0; in[i] && j + 1 < out_len; ++i) {
unsigned char c = (unsigned char)in[i];
if (g_ascii_isalnum(c) || c == '_' || c == '-') {
out[j++] = (char)c;
} else {
out[j++] = '_';
}
}
if (j == 0) {
g_strlcpy(out, "snapshot", out_len);
} else {
out[j] = '\0';
}
}
static char *get_snapshot_preview_dir(void)
{
const char *base = SDL_AndroidGetInternalStoragePath();
char *dir;
if (!base || !base[0]) {
return NULL;
}
dir = g_strdup_printf("%s/x1box/snapshots", base);
if (g_mkdir_with_parents(dir, 0700) != 0) {
SNAP_LOGW("failed to create snapshot preview dir: %s", dir);
g_free(dir);
return NULL;
}
return dir;
}
static char *get_snapshot_title(void)
{
struct xbe *xbe_data = xemu_get_xbe_info();
char *title = NULL;
if (xbe_data && xbe_data->cert) {
glong items_written = 0;
title = g_utf16_to_utf8((const gunichar2 *)xbe_data->cert->m_title_name,
40, NULL, &items_written, NULL);
if (title) {
g_strstrip(title);
if (title[0]) {
return title;
}
g_free(title);
title = NULL;
}
}
return g_strdup("Unknown Game");
}
static bool capture_snapshot_thumbnail(uint8_t **pixels_out, size_t *pixels_size_out)
{
GLint viewport[4] = { 0, 0, 0, 0 };
GLint prev_pack_alignment = 4;
uint8_t *src_pixels = NULL;
uint8_t *dst_pixels = NULL;
bool ok = false;
if (!pixels_out || !pixels_size_out) {
return false;
}
*pixels_out = NULL;
*pixels_size_out = 0;
if (!SDL_GL_GetCurrentContext() || g_snapshot_display_tex == 0) {
return false;
}
glGetIntegerv(GL_VIEWPORT, viewport);
if (viewport[2] <= 0 || viewport[3] <= 0) {
return false;
}
(void)g_snapshot_display_flip;
{
const int src_w = viewport[2];
const int src_h = viewport[3];
const size_t src_bytes = (size_t)src_w * (size_t)src_h * 4;
const size_t dst_bytes = (size_t)SNAPSHOT_PREVIEW_WIDTH *
(size_t)SNAPSHOT_PREVIEW_HEIGHT * 4;
src_pixels = g_malloc(src_bytes);
dst_pixels = g_malloc(dst_bytes);
glGetIntegerv(GL_PACK_ALIGNMENT, &prev_pack_alignment);
glPixelStorei(GL_PACK_ALIGNMENT, 1);
glReadPixels(viewport[0], viewport[1], src_w, src_h,
GL_RGBA, GL_UNSIGNED_BYTE, src_pixels);
glPixelStorei(GL_PACK_ALIGNMENT, prev_pack_alignment);
if (glGetError() != GL_NO_ERROR) {
goto cleanup;
}
for (int y = 0; y < SNAPSHOT_PREVIEW_HEIGHT; ++y) {
const int src_y = (int)(((int64_t)y * src_h) / SNAPSHOT_PREVIEW_HEIGHT);
for (int x = 0; x < SNAPSHOT_PREVIEW_WIDTH; ++x) {
const int src_x = (int)(((int64_t)x * src_w) / SNAPSHOT_PREVIEW_WIDTH);
const size_t src_off = ((size_t)src_y * (size_t)src_w + (size_t)src_x) * 4;
const size_t dst_off =
((size_t)y * (size_t)SNAPSHOT_PREVIEW_WIDTH + (size_t)x) * 4;
memcpy(dst_pixels + dst_off, src_pixels + src_off, 4);
}
}
}
*pixels_out = dst_pixels;
*pixels_size_out = (size_t)SNAPSHOT_PREVIEW_WIDTH *
(size_t)SNAPSHOT_PREVIEW_HEIGHT * 4;
dst_pixels = NULL;
ok = true;
cleanup:
g_free(src_pixels);
g_free(dst_pixels);
return ok;
}
static void write_snapshot_preview_sidecar(const char *vm_name)
{
char safe_name[128];
char *dir = NULL;
char *thumb_path = NULL;
char *title_path = NULL;
char *title = NULL;
uint8_t *pixels = NULL;
size_t pixels_size = 0;
FILE *title_file = NULL;
FILE *thumb_file = NULL;
if (!vm_name || !vm_name[0]) {
return;
}
sanitize_snapshot_name(vm_name, safe_name, sizeof(safe_name));
dir = get_snapshot_preview_dir();
if (!dir) {
return;
}
thumb_path = g_strdup_printf("%s/%s.thm", dir, safe_name);
title_path = g_strdup_printf("%s/%s.title", dir, safe_name);
title = get_snapshot_title();
if (title) {
title_file = fopen(title_path, "wb");
if (title_file) {
fwrite(title, 1, strlen(title), title_file);
fclose(title_file);
title_file = NULL;
}
}
if (!capture_snapshot_thumbnail(&pixels, &pixels_size)) {
SNAP_LOGW("snapshot preview capture failed for %s", vm_name);
goto cleanup;
}
{
SnapshotPreviewHeader header;
memcpy(header.magic, "X1TH", 4);
header.version = SNAPSHOT_PREVIEW_VERSION;
header.width = SNAPSHOT_PREVIEW_WIDTH;
header.height = SNAPSHOT_PREVIEW_HEIGHT;
header.channels = 4;
thumb_file = fopen(thumb_path, "wb");
if (!thumb_file) {
SNAP_LOGW("failed to open snapshot preview file: %s", thumb_path);
goto cleanup;
}
if (fwrite(&header, sizeof(header), 1, thumb_file) != 1 ||
fwrite(pixels, 1, pixels_size, thumb_file) != pixels_size) {
SNAP_LOGW("failed writing snapshot preview: %s", thumb_path);
}
fclose(thumb_file);
thumb_file = NULL;
}
cleanup:
if (thumb_file) {
fclose(thumb_file);
}
if (title_file) {
fclose(title_file);
}
g_free(pixels);
g_free(title);
g_free(title_path);
g_free(thumb_path);
g_free(dir);
}
char *xemu_get_currently_loaded_disc_path(void)
{
return NULL;
}
void xemu_snapshots_save(const char *vm_name, Error **err)
{
save_snapshot(vm_name, true, NULL, false, NULL, err);
xemu_snapshots_dirty = true;
}
void xemu_snapshots_load(const char *vm_name, Error **err)
{
bool was_running = runstate_is_running();
vm_stop(RUN_STATE_RESTORE_VM);
if (load_snapshot(vm_name, NULL, false, NULL, err) && was_running) {
vm_start();
}
}
void xemu_snapshots_delete(const char *vm_name, Error **err)
{
delete_snapshot(vm_name, false, NULL, err);
xemu_snapshots_dirty = true;
}
void xemu_snapshots_mark_dirty(void)
{
xemu_snapshots_dirty = true;
}
int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data,
Error **err)
{
@@ -21,49 +300,56 @@ int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data,
return 0;
}
void xemu_snapshots_load(const char *vm_name, Error **err)
{
(void)vm_name;
if (err) {
*err = NULL;
}
}
void xemu_snapshots_save(const char *vm_name, Error **err)
{
(void)vm_name;
if (err) {
*err = NULL;
}
}
void xemu_snapshots_delete(const char *vm_name, Error **err)
{
(void)vm_name;
if (err) {
*err = NULL;
}
}
void xemu_snapshots_save_extra_data(QEMUFile *f)
{
(void)f;
char *title = get_snapshot_title();
size_t title_size = title ? strlen(title) : 0;
if (title_size > 255) {
title_size = 255;
}
qemu_put_be32(f, XEMU_SNAPSHOT_DATA_MAGIC);
qemu_put_be32(f, XEMU_SNAPSHOT_DATA_VERSION);
qemu_put_be32(f, 4 + 1 + title_size + 4);
qemu_put_be32(f, 0);
qemu_put_byte(f, (uint8_t)title_size);
if (title_size) {
qemu_put_buffer(f, (const uint8_t *)title, title_size);
}
qemu_put_be32(f, 0);
g_free(title);
xemu_snapshots_dirty = true;
}
bool xemu_snapshots_offset_extra_data(QEMUFile *f)
{
(void)f;
return false;
}
unsigned int v;
uint32_t size;
void xemu_snapshots_mark_dirty(void)
{
v = qemu_get_be32(f);
if (v != XEMU_SNAPSHOT_DATA_MAGIC) {
qemu_file_skip(f, -4);
return true;
}
qemu_get_be32(f);
size = qemu_get_be32(f);
{
void *buf = g_malloc(size);
qemu_get_buffer(f, buf, size);
g_free(buf);
}
return true;
}
void xemu_snapshots_set_framebuffer_texture(GLuint tex, bool flip)
{
(void)tex;
(void)flip;
g_snapshot_display_tex = tex;
g_snapshot_display_flip = flip;
}
bool xemu_snapshots_load_png_to_texture(GLuint tex, void *buf, size_t size)
@@ -81,3 +367,96 @@ void *xemu_snapshots_create_framebuffer_thumbnail_png(size_t *size)
}
return NULL;
}
typedef enum SnapOpType {
SNAP_NONE,
SNAP_SAVE,
SNAP_LOAD,
} SnapOpType;
static struct {
pthread_mutex_t lock;
pthread_cond_t cond;
SnapOpType type;
char name[128];
bool pending;
bool done;
bool success;
} g_snap_req = {
.lock = PTHREAD_MUTEX_INITIALIZER,
.cond = PTHREAD_COND_INITIALIZER,
};
void xemu_android_process_snapshot_request(void)
{
if (pthread_mutex_trylock(&g_snap_req.lock) != 0) {
return;
}
if (!g_snap_req.pending) {
pthread_mutex_unlock(&g_snap_req.lock);
return;
}
Error *err = NULL;
if (g_snap_req.type == SNAP_SAVE) {
xemu_snapshots_save(g_snap_req.name, &err);
if (!err) {
write_snapshot_preview_sidecar(g_snap_req.name);
}
} else if (g_snap_req.type == SNAP_LOAD) {
xemu_snapshots_load(g_snap_req.name, &err);
}
if (err) {
SNAP_LOGW("snapshot op failed: %s", error_get_pretty(err));
error_free(err);
g_snap_req.success = false;
} else {
g_snap_req.success = true;
}
g_snap_req.pending = false;
g_snap_req.done = true;
pthread_cond_signal(&g_snap_req.cond);
pthread_mutex_unlock(&g_snap_req.lock);
}
static jboolean dispatch_snapshot(JNIEnv *env, jstring jname, SnapOpType type)
{
const char *name = (*env)->GetStringUTFChars(env, jname, NULL);
pthread_mutex_lock(&g_snap_req.lock);
g_snap_req.type = type;
g_snap_req.pending = true;
g_snap_req.done = false;
strncpy(g_snap_req.name, name, sizeof(g_snap_req.name) - 1);
g_snap_req.name[sizeof(g_snap_req.name) - 1] = '\0';
(*env)->ReleaseStringUTFChars(env, jname, name);
while (!g_snap_req.done) {
pthread_cond_wait(&g_snap_req.cond, &g_snap_req.lock);
}
jboolean ok = (jboolean)g_snap_req.success;
g_snap_req.type = SNAP_NONE;
pthread_mutex_unlock(&g_snap_req.lock);
return ok;
}
JNIEXPORT jboolean JNICALL
Java_com_izzy2lost_x1box_MainActivity_nativeSaveSnapshot(
JNIEnv *env, jobject obj, jstring name)
{
(void)obj;
return dispatch_snapshot(env, name, SNAP_SAVE);
}
JNIEXPORT jboolean JNICALL
Java_com_izzy2lost_x1box_MainActivity_nativeLoadSnapshot(
JNIEnv *env, jobject obj, jstring name)
{
(void)obj;
return dispatch_snapshot(env, name, SNAP_LOAD);
}
@@ -2,6 +2,7 @@ package com.izzy2lost.x1box
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.text.SpannableStringBuilder
@@ -10,9 +11,12 @@ import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ProgressBar
import android.widget.Space
import android.widget.TextView
@@ -30,6 +34,8 @@ import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.net.URLEncoder
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
@@ -37,6 +43,8 @@ import java.util.concurrent.ConcurrentHashMap
class GameLibraryActivity : AppCompatActivity() {
companion object {
const val EXTRA_RESTART_LAST_GAME = "com.izzy2lost.x1box.extra.RESTART_LAST_GAME"
private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12
private const val TOTAL_SNAPSHOT_SLOTS = 10
}
private data class GameEntry(
@@ -53,6 +61,13 @@ class GameLibraryActivity : AppCompatActivity() {
val url: String
)
private data class SnapshotSlotPreview(
val slot: Int,
val slotLabel: String,
val gameTitle: String,
val thumbnail: Bitmap?,
)
private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) }
private val gameExts = setOf("iso", "xiso", "cso", "cci")
private val titleStopWords = setOf("the", "a", "an", "and", "of", "for", "in", "on", "to")
@@ -72,6 +87,7 @@ class GameLibraryActivity : AppCompatActivity() {
private lateinit var gamesGridContainer: LinearLayout
private lateinit var btnChangeFolder: MaterialButton
private lateinit var btnSettings: MaterialButton
private lateinit var btnSnapshots: MaterialButton
private lateinit var btnConvertIso: MaterialButton
private lateinit var btnAbout: ImageButton
private lateinit var viewModeToggle: MaterialButtonToggleGroup
@@ -112,6 +128,7 @@ class GameLibraryActivity : AppCompatActivity() {
gamesGridContainer = findViewById(R.id.library_games_grid_container)
btnChangeFolder = findViewById(R.id.btn_change_games_folder)
btnSettings = findViewById(R.id.btn_settings)
btnSnapshots = findViewById(R.id.btn_snapshots)
btnConvertIso = findViewById(R.id.btn_convert_iso)
btnAbout = findViewById(R.id.btn_library_about)
viewModeToggle = findViewById(R.id.library_view_mode_toggle)
@@ -135,6 +152,9 @@ class GameLibraryActivity : AppCompatActivity() {
btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
btnSnapshots.setOnClickListener {
showSnapshotStartupPicker()
}
btnConvertIso.setOnClickListener {
showIsoConversionPicker()
}
@@ -216,6 +236,226 @@ class GameLibraryActivity : AppCompatActivity() {
finish()
}
private fun slotName(slot: Int) = "android_slot_$slot"
private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots")
private fun snapshotPreviewDirs(): List<File> {
val dirs = ArrayList<File>(2)
dirs.add(snapshotPreviewDir())
getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) }
return dirs.distinctBy { it.absolutePath }
}
private fun slotNameAliases(slot: Int): List<String> {
val aliases = linkedSetOf(
slotName(slot),
"slot_$slot",
"slot$slot",
"snapshot_$slot",
)
return aliases.toList()
}
private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? {
for (dir in snapshotPreviewDirs()) {
for (name in slotNameAliases(slot)) {
val file = File(dir, "$name.$extension")
if (file.isFile) {
return file
}
}
}
return null
}
private fun extractDisplayName(rawName: String?): String? {
if (rawName.isNullOrBlank()) {
return null
}
val decoded = Uri.decode(rawName)
val leaf = decoded.substringAfterLast('/').substringAfterLast(':')
if (leaf.isBlank()) {
return null
}
val stem = leaf.substringBeforeLast('.', leaf).trim()
return stem.takeIf { it.isNotEmpty() }
}
private fun fallbackCurrentGameName(): String {
val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name })
if (!pathName.isNullOrEmpty()) {
return pathName
}
val uriName = extractDisplayName(prefs.getString("dvdUri", null))
if (!uriName.isNullOrEmpty()) {
return uriName
}
return getString(R.string.snapshot_unknown_game)
}
private fun readSnapshotGameTitle(slot: Int): String {
val title = runCatching {
val file = resolveSnapshotPreviewFile(slot, "title")
if (file != null && file.exists()) {
file.readText(Charsets.UTF_8).trim()
} else {
""
}
}.getOrDefault("")
if (title.isNotEmpty()) {
return title
}
return if (resolveSnapshotPreviewFile(slot, "thm") != null) {
fallbackCurrentGameName()
} else {
getString(R.string.snapshot_empty_slot)
}
}
private fun decodeSnapshotThumbnail(slot: Int): Bitmap? {
val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null
val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) {
return null
}
if (bytes[0] != 'X'.code.toByte() ||
bytes[1] != '1'.code.toByte() ||
bytes[2] != 'T'.code.toByte() ||
bytes[3] != 'H'.code.toByte()) {
return null
}
val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN)
val version = header.short.toInt() and 0xFFFF
val width = header.short.toInt() and 0xFFFF
val height = header.short.toInt() and 0xFFFF
val channels = header.short.toInt() and 0xFFFF
if (version != 1 || channels != 4 || width <= 0 || height <= 0) {
return null
}
val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong()
if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) {
return null
}
val pixelBytes = pixelBytesLong.toInt()
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) {
return null
}
val pixels = IntArray(width * height)
var src = SNAPSHOT_PREVIEW_HEADER_SIZE
for (y in 0 until height) {
val dstRow = (height - 1 - y) * width
for (x in 0 until width) {
val r = bytes[src].toInt() and 0xFF
val g = bytes[src + 1].toInt() and 0xFF
val b = bytes[src + 2].toInt() and 0xFF
pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
src += 4
}
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
private fun loadSnapshotSlotPreviews(): List<SnapshotSlotPreview> {
return (1..TOTAL_SNAPSHOT_SLOTS).map { slot ->
SnapshotSlotPreview(
slot = slot,
slotLabel = getString(R.string.snapshot_slot_label, slot),
gameTitle = readSnapshotGameTitle(slot),
thumbnail = decodeSnapshotThumbnail(slot),
)
}
}
private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) {
val bitmap = preview.thumbnail ?: return
val image = ImageView(this).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
scaleType = ImageView.ScaleType.FIT_CENTER
setPadding(16, 16, 16, 16)
}
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle))
.setView(image)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun launchMainActivityWithSnapshot(slot: Int) {
val intent = Intent(this, MainActivity::class.java).apply {
putExtra(MainActivity.EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, slot)
}
startActivity(intent)
finish()
}
private fun showSnapshotStartupPicker() {
val previews = loadSnapshotSlotPreviews()
val listView = ListView(this)
lateinit var dialog: androidx.appcompat.app.AlertDialog
val adapter = object : BaseAdapter() {
override fun getCount(): Int = previews.size
override fun getItem(position: Int): SnapshotSlotPreview = previews[position]
override fun getItemId(position: Int): Long = previews[position].slot.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false)
val preview = getItem(position)
val slotLabel = view.findViewById<TextView>(R.id.snapshot_slot_label)
val gameTitle = view.findViewById<TextView>(R.id.snapshot_game_title)
val previewHint = view.findViewById<TextView>(R.id.snapshot_preview_hint)
val thumbnail = view.findViewById<ImageView>(R.id.snapshot_thumbnail)
slotLabel.text = preview.slotLabel
gameTitle.text = preview.gameTitle
if (preview.thumbnail != null) {
thumbnail.setImageBitmap(preview.thumbnail)
previewHint.text = getString(R.string.snapshot_preview_tap_hint)
previewHint.visibility = View.VISIBLE
thumbnail.setOnClickListener {
showSnapshotPreviewDialog(preview)
}
} else {
thumbnail.setImageResource(android.R.drawable.ic_menu_report_image)
previewHint.text = getString(R.string.snapshot_preview_unavailable)
previewHint.visibility = View.VISIBLE
thumbnail.setOnClickListener(null)
}
return view
}
}
listView.adapter = adapter
listView.setOnItemClickListener { _, _, position, _ ->
val slot = previews[position].slot
dialog.dismiss()
launchMainActivityWithSnapshot(slot)
}
dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(R.string.snapshot_select_load_slot)
.setView(listView)
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.show()
}
private fun loadGames() {
val folderUri = gamesFolderUri
if (!isFolderReady(folderUri)) {
@@ -2,20 +2,44 @@ package com.izzy2lost.x1box
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.hardware.input.InputManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.widget.BaseAdapter
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.libsdl.app.SDLActivity
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
companion object {
const val EXTRA_AUTO_LOAD_SNAPSHOT_SLOT = "com.izzy2lost.x1box.extra.AUTO_LOAD_SNAPSHOT_SLOT"
private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12
private const val TOTAL_SNAPSHOT_SLOTS = 10
}
private data class SnapshotSlotPreview(
val slot: Int,
val slotLabel: String,
val gameTitle: String,
val thumbnail: Bitmap?,
)
private var onScreenController: OnScreenController? = null
private var controllerBridge: ControllerInputBridge? = null
private var isControllerVisible = false
@@ -25,9 +49,15 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
private var startButtonDown = false
private var selectButtonDown = false
private var comboTriggered = false
private var startupSnapshotSlot: Int? = null
private var startupSnapshotLoadScheduled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val requestedSlot = intent?.getIntExtra(EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, 0) ?: 0
if (requestedSlot in 1..TOTAL_SNAPSHOT_SLOTS) {
startupSnapshotSlot = requestedSlot
}
setupOnScreenController()
setupControllerDetection()
hideSystemUI()
@@ -119,6 +149,35 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
mLayout?.postDelayed({
registerVirtualController()
}, 1000)
scheduleStartupSnapshotLoadIfRequested()
}
private fun scheduleStartupSnapshotLoadIfRequested() {
val slot = startupSnapshotSlot ?: return
if (startupSnapshotLoadScheduled) {
return
}
startupSnapshotLoadScheduled = true
val hostView = mLayout ?: window.decorView
hostView.postDelayed({
Thread {
val ok = nativeLoadSnapshot(slotName(slot))
runOnUiThread {
if (ok) {
writeSnapshotTitleFallback(slot)
}
val msg = if (ok) {
getString(R.string.snapshot_loaded, slot)
} else {
getString(R.string.snapshot_load_failed, slot)
}
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}.start()
startupSnapshotSlot = null
}, 2500)
}
private fun registerVirtualController() {
@@ -285,6 +344,273 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
((source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)
}
private external fun nativeSaveSnapshot(name: String): Boolean
private external fun nativeLoadSnapshot(name: String): Boolean
private fun slotName(slot: Int) = "android_slot_$slot"
private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots")
private fun snapshotPreviewDirs(): List<File> {
val dirs = ArrayList<File>(2)
dirs.add(snapshotPreviewDir())
getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) }
return dirs.distinctBy { it.absolutePath }
}
private fun slotNameAliases(slot: Int): List<String> {
val aliases = linkedSetOf(
slotName(slot),
"slot_$slot",
"slot$slot",
"snapshot_$slot",
)
return aliases.toList()
}
private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? {
for (dir in snapshotPreviewDirs()) {
for (name in slotNameAliases(slot)) {
val file = File(dir, "$name.$extension")
if (file.isFile) {
return file
}
}
}
return null
}
private fun snapshotPreviewTitleFile(slot: Int): File =
File(snapshotPreviewDir(), "${slotName(slot)}.title")
private fun extractDisplayName(rawName: String?): String? {
if (rawName.isNullOrBlank()) {
return null
}
val decoded = Uri.decode(rawName)
val leaf = decoded.substringAfterLast('/').substringAfterLast(':')
if (leaf.isBlank()) {
return null
}
val stem = leaf.substringBeforeLast('.', leaf).trim()
return stem.takeIf { it.isNotEmpty() }
}
private fun fallbackCurrentGameName(): String {
val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE)
val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name })
if (!pathName.isNullOrEmpty()) {
return pathName
}
val uriName = extractDisplayName(prefs.getString("dvdUri", null))
if (!uriName.isNullOrEmpty()) {
return uriName
}
return getString(R.string.snapshot_unknown_game)
}
private fun writeSnapshotTitleFallback(slot: Int) {
val file = snapshotPreviewTitleFile(slot)
if (runCatching { file.exists() && file.readText(Charsets.UTF_8).trim().isNotEmpty() }.getOrDefault(false)) {
return
}
val title = fallbackCurrentGameName().trim()
if (title.isEmpty() || title == getString(R.string.snapshot_unknown_game)) {
return
}
runCatching {
val dir = snapshotPreviewDir()
if (!dir.exists()) {
dir.mkdirs()
}
file.writeText(title, Charsets.UTF_8)
}
}
private fun readSnapshotGameTitle(slot: Int): String {
val title = runCatching {
val file = resolveSnapshotPreviewFile(slot, "title")
if (file != null && file.exists()) {
file.readText(Charsets.UTF_8).trim()
} else {
""
}
}.getOrDefault("")
if (title.isNotEmpty()) {
return title
}
return if (resolveSnapshotPreviewFile(slot, "thm") != null) {
fallbackCurrentGameName()
} else {
getString(R.string.snapshot_empty_slot)
}
}
private fun decodeSnapshotThumbnail(slot: Int): Bitmap? {
val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null
val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) {
return null
}
if (bytes[0] != 'X'.code.toByte() ||
bytes[1] != '1'.code.toByte() ||
bytes[2] != 'T'.code.toByte() ||
bytes[3] != 'H'.code.toByte()) {
return null
}
val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN)
val version = header.short.toInt() and 0xFFFF
val width = header.short.toInt() and 0xFFFF
val height = header.short.toInt() and 0xFFFF
val channels = header.short.toInt() and 0xFFFF
if (version != 1 || channels != 4 || width <= 0 || height <= 0) {
return null
}
val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong()
if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) {
return null
}
val pixelBytes = pixelBytesLong.toInt()
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) {
return null
}
val pixels = IntArray(width * height)
var src = SNAPSHOT_PREVIEW_HEADER_SIZE
for (y in 0 until height) {
val dstRow = (height - 1 - y) * width
for (x in 0 until width) {
val r = bytes[src].toInt() and 0xFF
val g = bytes[src + 1].toInt() and 0xFF
val b = bytes[src + 2].toInt() and 0xFF
pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
src += 4
}
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
private fun loadSnapshotSlotPreviews(): List<SnapshotSlotPreview> {
return (1..TOTAL_SNAPSHOT_SLOTS).map { slot ->
SnapshotSlotPreview(
slot = slot,
slotLabel = getString(R.string.snapshot_slot_label, slot),
gameTitle = readSnapshotGameTitle(slot),
thumbnail = decodeSnapshotThumbnail(slot),
)
}
}
private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) {
val bitmap = preview.thumbnail ?: return
val image = ImageView(this).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
scaleType = ImageView.ScaleType.FIT_CENTER
setPadding(16, 16, 16, 16)
}
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle))
.setView(image)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun runSnapshotOperation(slot: Int, save: Boolean) {
Thread {
val ok = if (save) nativeSaveSnapshot(slotName(slot)) else nativeLoadSnapshot(slotName(slot))
runOnUiThread {
if (ok) {
writeSnapshotTitleFallback(slot)
}
val msg = if (save) {
if (ok) getString(R.string.snapshot_saved, slot) else getString(R.string.snapshot_save_failed)
} else {
if (ok) getString(R.string.snapshot_loaded, slot) else getString(R.string.snapshot_load_failed, slot)
}
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}.start()
}
private fun showSnapshotSlotDialog(save: Boolean) {
val previews = loadSnapshotSlotPreviews()
val listView = ListView(this)
lateinit var dialog: AlertDialog
val adapter = object : BaseAdapter() {
override fun getCount(): Int = previews.size
override fun getItem(position: Int): SnapshotSlotPreview = previews[position]
override fun getItemId(position: Int): Long = previews[position].slot.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false)
val preview = getItem(position)
val slotLabel = view.findViewById<TextView>(R.id.snapshot_slot_label)
val gameTitle = view.findViewById<TextView>(R.id.snapshot_game_title)
val previewHint = view.findViewById<TextView>(R.id.snapshot_preview_hint)
val thumbnail = view.findViewById<ImageView>(R.id.snapshot_thumbnail)
slotLabel.text = preview.slotLabel
gameTitle.text = preview.gameTitle
if (preview.thumbnail != null) {
thumbnail.setImageBitmap(preview.thumbnail)
previewHint.text = getString(R.string.snapshot_preview_tap_hint)
previewHint.visibility = View.VISIBLE
thumbnail.setOnClickListener {
showSnapshotPreviewDialog(preview)
}
} else {
thumbnail.setImageResource(android.R.drawable.ic_menu_report_image)
previewHint.text = getString(R.string.snapshot_preview_unavailable)
previewHint.visibility = View.VISIBLE
thumbnail.setOnClickListener(null)
}
return view
}
}
listView.adapter = adapter
listView.setOnItemClickListener { _, _, position, _ ->
val slot = previews[position].slot
dialog.dismiss()
runSnapshotOperation(slot, save)
}
dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
.setTitle(
if (save) getString(R.string.snapshot_select_save_slot)
else getString(R.string.snapshot_select_load_slot)
)
.setView(listView)
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.show()
}
private fun showSaveStateDialog() {
showSnapshotSlotDialog(save = true)
}
private fun showLoadStateDialog() {
showSnapshotSlotDialog(save = false)
}
private fun showInGameMenu() {
val options = arrayOf(
getString(R.string.in_game_menu_resume),
@@ -293,6 +619,8 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
} else {
getString(R.string.in_game_menu_show_touch_controls)
},
getString(R.string.in_game_menu_save_state),
getString(R.string.in_game_menu_load_state),
getString(R.string.in_game_menu_exit_to_library),
getString(R.string.in_game_menu_quit_app),
)
@@ -301,12 +629,12 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
.setTitle(getString(R.string.in_game_menu_title))
.setItems(options) { _, which ->
when (which) {
0 -> {
// Resume
}
0 -> { /* Resume — dismiss dialog */ }
1 -> toggleOnScreenController()
2 -> exitToGameLibrary()
3 -> finishAffinity()
2 -> showSaveStateDialog()
3 -> showLoadStateDialog()
4 -> exitToGameLibrary()
5 -> finishAffinity()
}
}
.setOnDismissListener {
@@ -97,6 +97,17 @@
app:iconGravity="textStart"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_snapshots"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/library_snapshots"
app:icon="@android:drawable/ic_menu_recent_history"
app:iconGravity="textStart"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_convert_iso"
style="?attr/materialButtonOutlinedStyle"
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="96dp"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<ImageView
android:id="@+id/snapshot_thumbnail"
android:layout_width="120dp"
android:layout_height="90dp"
android:background="#1AFFFFFF"
android:contentDescription="@string/snapshot_thumbnail_desc"
android:scaleType="centerCrop" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/snapshot_slot_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<TextView
android:id="@+id/snapshot_game_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<TextView
android:id="@+id/snapshot_preview_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
</LinearLayout>
</LinearLayout>
@@ -29,6 +29,7 @@
<string name="library_title">Game Library</string>
<string name="library_change_folder">Change Folder</string>
<string name="library_snapshots">Snapshots</string>
<string name="library_convert_iso">Convert ISO to XISO</string>
<string name="library_view_list">List</string>
<string name="library_view_cover_grid">Cover Grid</string>
@@ -108,4 +109,19 @@
<string name="in_game_menu_hide_touch_controls">Hide Touch Controls</string>
<string name="in_game_menu_exit_to_library">Exit to Game Library</string>
<string name="in_game_menu_quit_app">Quit App</string>
<string name="in_game_menu_save_state">Save State</string>
<string name="in_game_menu_load_state">Load State</string>
<string name="snapshot_slot_label">Slot %1$d</string>
<string name="snapshot_saved">State saved to Slot %1$d</string>
<string name="snapshot_save_failed">Save failed</string>
<string name="snapshot_loaded">State loaded from Slot %1$d</string>
<string name="snapshot_load_failed">No save found in Slot %1$d</string>
<string name="snapshot_select_save_slot">Select slot to save</string>
<string name="snapshot_select_load_slot">Select slot to load</string>
<string name="snapshot_empty_slot">Empty slot</string>
<string name="snapshot_unknown_game">Unknown game</string>
<string name="snapshot_preview_tap_hint">Tap thumbnail to enlarge</string>
<string name="snapshot_preview_unavailable">No screenshot yet</string>
<string name="snapshot_preview_title">Slot %1$d - %2$s</string>
<string name="snapshot_thumbnail_desc">Snapshot thumbnail</string>
</resources>
+1 -1
View File
@@ -27,7 +27,7 @@ extern "C" {
#include "qemu/osdep.h"
#include "block/snapshot.h"
#if defined(ANDROID) || defined(__ANDROID__)
typedef unsigned int GLuint;
#include <GLES3/gl3.h>
#else
#include <epoxy/gl.h>
#endif
+8 -3
View File
@@ -1762,9 +1762,14 @@ void sdl2_gl_refresh(DisplayChangeListener *dcl)
glClear(GL_COLOR_BUFFER_BIT);
#ifdef __ANDROID__
android_blit_frame(tex, flip_required);
#else
#endif
#ifdef __ANDROID__
/*
* Android uses a HUD stub, but snapshot JNI requests are dispatched
* from xemu_hud_render(). Keep this hook active each frame so save/load
* requests run on the SDL/QEMU render thread.
*/
xemu_snapshots_set_framebuffer_texture(tex, flip_required);
xemu_hud_set_framebuffer_texture(tex, flip_required);
xemu_hud_render();
android_log_gl_error("refresh-blit");
#else
xemu_snapshots_set_framebuffer_texture(tex, flip_required);