diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ea7ff550a7..1a1bf3d5b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -53,12 +53,10 @@ diff --git a/android/app/src/main/cpp/xemu_android.cpp b/android/app/src/main/cpp/xemu_android.cpp index 28f88c9241..db88fe3749 100644 --- a/android/app/src/main/cpp/xemu_android.cpp +++ b/android/app/src/main/cpp/xemu_android.cpp @@ -13,9 +13,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -36,34 +38,68 @@ extern "C" AddfdInfo* monitor_fdset_add_fd(int fd, bool has_fdset_id, namespace { constexpr const char* kLogTag = "xemu-android"; constexpr const char* kPrefsName = "x1box_prefs"; +constexpr const char* kDebugLogPrefKey = "setting_debug_logs_enabled"; +constexpr const char* kDebugLogRelativeDir = "x1box/debug-logs"; +constexpr const char* kNativeDebugLogFileName = "xemu-debug.log"; +constexpr off_t kMaxDebugLogBytes = 4 * 1024 * 1024; static std::atomic g_qemu_init_started{false}; +static std::atomic g_native_debug_logging_enabled{false}; +static std::string g_native_debug_log_path; +static SDL_mutex* g_native_debug_log_mutex = nullptr; static JNIEnv* GetEnv(); static jobject GetActivity(JNIEnv* env); static bool HasException(JNIEnv* env, const char* context); +static std::string GetFilesDirPath(JNIEnv* env, jobject activity); +static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity); +static bool NativeDebugLoggingEnabled(); +static void AppendNativeDebugLog(const char* level, const char* message); static void LogInfo(const char* msg) { + if (!NativeDebugLoggingEnabled()) { + return; + } __android_log_print(ANDROID_LOG_INFO, kLogTag, "%s", msg); + AppendNativeDebugLog("I", msg); } static void LogInfoFmt(const char* fmt, const char* detail) { + if (!NativeDebugLoggingEnabled()) { + return; + } __android_log_print(ANDROID_LOG_INFO, kLogTag, fmt, detail); + char buffer[1024] = {}; + std::snprintf(buffer, sizeof(buffer), fmt, detail); + AppendNativeDebugLog("I", buffer); } static void LogInfoInt(const char* fmt, int value) { + if (!NativeDebugLoggingEnabled()) { + return; + } __android_log_print(ANDROID_LOG_INFO, kLogTag, fmt, value); + char buffer[1024] = {}; + std::snprintf(buffer, sizeof(buffer), fmt, value); + AppendNativeDebugLog("I", buffer); } static void LogError(const char* msg) { __android_log_print(ANDROID_LOG_ERROR, kLogTag, "%s", msg); + AppendNativeDebugLog("E", msg); } static void LogErrorInt(const char* fmt, int value) { __android_log_print(ANDROID_LOG_ERROR, kLogTag, fmt, value); + char buffer[1024] = {}; + std::snprintf(buffer, sizeof(buffer), fmt, value); + AppendNativeDebugLog("E", buffer); } static void LogErrorFmt(const char* fmt, const char* detail) { __android_log_print(ANDROID_LOG_ERROR, kLogTag, fmt, detail); + char buffer[1024] = {}; + std::snprintf(buffer, sizeof(buffer), fmt, detail); + AppendNativeDebugLog("E", buffer); } static int g_next_dvd_fdset_id = 9000; @@ -80,6 +116,43 @@ static bool FileExists(const std::string& path) { return stat(path.c_str(), &st) == 0; } +static std::string CurrentNativeLogTimestamp() { + std::time_t now = std::time(nullptr); + std::tm local_time {}; + localtime_r(&now, &local_time); + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local_time); + return std::string(buffer); +} + +static bool NativeDebugLoggingEnabled() { + return g_native_debug_logging_enabled.load() && !g_native_debug_log_path.empty(); +} + +static void AppendNativeDebugLog(const char* level, const char* message) { + if (!NativeDebugLoggingEnabled() || !level || !message || !g_native_debug_log_mutex) { + return; + } + + SDL_LockMutex(g_native_debug_log_mutex); + + struct stat st {}; + const bool should_truncate = + stat(g_native_debug_log_path.c_str(), &st) == 0 && + st.st_size > kMaxDebugLogBytes; + + std::ofstream out( + g_native_debug_log_path, + should_truncate ? (std::ios::out | std::ios::trunc) + : (std::ios::out | std::ios::app)); + if (out.is_open()) { + out << CurrentNativeLogTimestamp() << ' ' << level << '/' << kLogTag + << ": " << message << '\n'; + } + + SDL_UnlockMutex(g_native_debug_log_mutex); +} + static bool IsTcgTuningEnabled() { const char* value = SDL_getenv("XEMU_ANDROID_TCG_TUNING"); return !(value && value[0] == '0'); @@ -261,6 +334,57 @@ static std::string JStringToString(JNIEnv* env, jstring value) { return out; } +static std::string GetFilesDirPath(JNIEnv* env, jobject activity) { + if (!env || !activity) { + return {}; + } + + jclass activityClass = env->GetObjectClass(activity); + if (!activityClass) { + return {}; + } + + jmethodID getFilesDir = + env->GetMethodID(activityClass, "getFilesDir", "()Ljava/io/File;"); + env->DeleteLocalRef(activityClass); + if (!getFilesDir) { + return {}; + } + + jobject fileObj = env->CallObjectMethod(activity, getFilesDir); + if (HasException(env, "Activity.getFilesDir") || !fileObj) { + return {}; + } + + jclass fileClass = env->GetObjectClass(fileObj); + if (!fileClass) { + env->DeleteLocalRef(fileObj); + return {}; + } + + jmethodID getAbsolutePath = + env->GetMethodID(fileClass, "getAbsolutePath", "()Ljava/lang/String;"); + if (!getAbsolutePath) { + env->DeleteLocalRef(fileClass); + env->DeleteLocalRef(fileObj); + return {}; + } + + jstring pathValue = static_cast( + env->CallObjectMethod(fileObj, getAbsolutePath)); + std::string path; + if (!HasException(env, "File.getAbsolutePath")) { + path = JStringToString(env, pathValue); + } + + if (pathValue) { + env->DeleteLocalRef(pathValue); + } + env->DeleteLocalRef(fileClass); + env->DeleteLocalRef(fileObj); + return path; +} + static bool HasInlineAioCrashFlag(const std::string& flag_path) { if (flag_path.empty()) { return false; @@ -427,6 +551,31 @@ static int GetPrefInt(JNIEnv* env, jobject activity, const char* key, int defVal return out; } +static void ConfigureNativeDebugLogging(JNIEnv* env, jobject activity) { + g_native_debug_logging_enabled.store( + GetPrefBool(env, activity, kDebugLogPrefKey, false)); + g_native_debug_log_path.clear(); + + if (!g_native_debug_logging_enabled.load()) { + return; + } + + const std::string files_dir = GetFilesDirPath(env, activity); + if (files_dir.empty()) { + g_native_debug_logging_enabled.store(false); + return; + } + + const std::string log_dir = files_dir + "/" + kDebugLogRelativeDir; + EnsureDirExists(files_dir + "/x1box"); + EnsureDirExists(log_dir); + g_native_debug_log_path = log_dir + "/" + kNativeDebugLogFileName; + + if (!g_native_debug_log_mutex) { + g_native_debug_log_mutex = SDL_CreateMutex(); + } +} + static bool IsSeekableFd(int fd) { errno = 0; return lseek(fd, 0, SEEK_CUR) != static_cast(-1); @@ -723,7 +872,7 @@ struct EmulatorSettings { bool hrtf = true; bool cache_shaders = true; bool hard_fpu = true; - bool vsync = true; + bool vsync = false; bool skip_boot_anim = false; bool network_enabled = false; }; @@ -990,7 +1139,7 @@ static SetupFiles SyncSetupFiles() { emuSettings.filtering = "nearest"; } } - emuSettings.vsync = GetPrefBool(env, activity, "setting_vsync", true); + emuSettings.vsync = GetPrefBool(env, activity, "setting_vsync", false); out.audio_driver = GetPrefString(env, activity, "setting_audio_driver"); { std::string normalized = ToLowerAscii(out.audio_driver); @@ -1070,6 +1219,7 @@ extern "C" int SDL_main(int argc, char* argv[]) { (void)argc; (void)argv; + ConfigureNativeDebugLogging(GetEnv(), GetActivity(GetEnv())); LogInfo("SDL_main: start"); std::string audio_driver_hint = ResolveAndroidAudioDriverHint(); SDL_SetHintWithPriority(SDL_HINT_AUDIODRIVER, audio_driver_hint.c_str(), diff --git a/android/app/src/main/cpp/xemu_settings_android.cc b/android/app/src/main/cpp/xemu_settings_android.cc index 7618b3849e..41beaf0947 100644 --- a/android/app/src/main/cpp/xemu_settings_android.cc +++ b/android/app/src/main/cpp/xemu_settings_android.cc @@ -81,7 +81,7 @@ static void xemu_settings_apply_defaults(void) CONFIG_DISPLAY_WINDOW_STARTUP_SIZE_1280X960; g_config.display.window.last_width = 640; g_config.display.window.last_height = 480; - g_config.display.window.vsync = true; + g_config.display.window.vsync = false; g_config.display.ui.show_menubar = true; g_config.display.ui.show_notifications = true; g_config.display.ui.hide_cursor = true; diff --git a/android/app/src/main/java/com/izzy2lost/x1box/ControllerInputBridge.kt b/android/app/src/main/java/com/izzy2lost/x1box/ControllerInputBridge.kt index 27cc5699e5..b0c5686924 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/ControllerInputBridge.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/ControllerInputBridge.kt @@ -33,7 +33,7 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } } } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "Error on button press: ${e.message}") + DebugLog.e("ControllerBridge", e) { "Error on button press: ${e.message}" } } } @@ -49,7 +49,7 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } } } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "Error on button release: ${e.message}") + DebugLog.e("ControllerBridge", e) { "Error on button release: ${e.message}" } } } @@ -66,7 +66,7 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } } } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "Error on stick move: ${e.message}") + DebugLog.e("ControllerBridge", e) { "Error on stick move: ${e.message}" } } } @@ -78,7 +78,7 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } SDLControllerManager.onNativePadDown(VIRTUAL_DEVICE_ID, keyCode) } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "Error on stick press: ${e.message}") + DebugLog.e("ControllerBridge", e) { "Error on stick press: ${e.message}" } } } @@ -90,7 +90,7 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } SDLControllerManager.onNativePadUp(VIRTUAL_DEVICE_ID, keyCode) } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "Error on stick release: ${e.message}") + DebugLog.e("ControllerBridge", e) { "Error on stick release: ${e.message}" } } } @@ -113,12 +113,12 @@ class ControllerInputBridge : OnScreenController.ControllerListener { SDLControllerManager.onNativePadUp(VIRTUAL_DEVICE_ID, keyCode) } } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "SDL pad event failed for $button: ${e.message}") + DebugLog.e("ControllerBridge", e) { "SDL pad event failed for $button: ${e.message}" } } try { SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, axis, axisValue) } catch (e: Exception) { - android.util.Log.e("ControllerBridge", "SDL joy event failed for $button: ${e.message}") + DebugLog.e("ControllerBridge", e) { "SDL joy event failed for $button: ${e.message}" } } } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/DebugLog.kt b/android/app/src/main/java/com/izzy2lost/x1box/DebugLog.kt new file mode 100644 index 0000000000..10da3be08c --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/DebugLog.kt @@ -0,0 +1,319 @@ +package com.izzy2lost.x1box + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors + +object DebugLog { + const val PREF_ENABLED = "setting_debug_logs_enabled" + + private const val TAG = "xemu-android" + private const val LOG_DIR = "x1box/debug-logs" + private const val UI_LOG_FILE_NAME = "ui-debug.log" + private const val NATIVE_LOG_FILE_NAME = "xemu-debug.log" + private const val UI_LOGCAT_FILE_NAME = "ui-logcat.log" + private const val XEMU_LOGCAT_FILE_NAME = "xemu-logcat.log" + private const val MAX_LOG_BYTES = 16L * 1024L * 1024L + + @Volatile private var appContext: Context? = null + @PublishedApi + @Volatile + internal var enabled = false + @Volatile private var logcatProcess: java.lang.Process? = null + @Volatile private var logcatThread: Thread? = null + @Volatile private var activeLogcatPath: String? = null + + private val writerExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "x1box-debug-log-writer").apply { + isDaemon = true + } + } + + fun initialize(context: Context) { + val applicationContext = context.applicationContext + appContext = applicationContext + enabled = applicationContext + .getSharedPreferences("x1box_prefs", Context.MODE_PRIVATE) + .getBoolean(PREF_ENABLED, false) + ensureLogcatCaptureState(applicationContext) + } + + fun setEnabled(context: Context, value: Boolean, resetLogs: Boolean = false) { + initialize(context) + if (!value) { + stopLogcatCapture() + } + if (resetLogs) { + clearLogs(context) + } + enabled = value + if (value) { + ensureLogcatCaptureState(context.applicationContext) + } + if (value) { + i(TAG) { "Debug logging enabled" } + } else { + Log.i(TAG, "Debug logging disabled") + } + } + + fun hasAnyLog(context: Context): Boolean { + return uiLogFile(context).isFile || + nativeLogFile(context).isFile || + uiLogcatFile(context).isFile || + xemuLogcatFile(context).isFile + } + + fun exportDefaultFileName(): String { + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + return "x1box-debug-$stamp.log" + } + + @Throws(Exception::class) + fun exportCombined(context: Context, outputStream: OutputStream) { + val uiLog = uiLogFile(context) + val nativeLog = nativeLogFile(context) + if (!uiLog.isFile && !nativeLog.isFile) { + throw IllegalStateException("No debug log captured yet.") + } + + outputStream.bufferedWriter().use { writer -> + if (uiLog.isFile) { + writer.appendLine("=== UI Debug Log ===") + uiLog.bufferedReader().useLines { lines -> + lines.forEach(writer::appendLine) + } + } + + if (nativeLog.isFile) { + if (uiLog.isFile) { + writer.appendLine() + } + writer.appendLine("=== xemu Native Debug Log ===") + nativeLog.bufferedReader().useLines { lines -> + lines.forEach(writer::appendLine) + } + } + + val uiLogcat = uiLogcatFile(context) + if (uiLogcat.isFile) { + writer.appendLine() + writer.appendLine("=== UI Logcat Capture ===") + uiLogcat.bufferedReader().useLines { lines -> + lines.forEach(writer::appendLine) + } + } + + val xemuLogcat = xemuLogcatFile(context) + if (xemuLogcat.isFile) { + writer.appendLine() + writer.appendLine("=== xemu Logcat Capture ===") + xemuLogcat.bufferedReader().useLines { lines -> + lines.forEach(writer::appendLine) + } + } + } + } + + fun clearLogs(context: Context? = appContext) { + context ?: return + stopLogcatCapture() + uiLogFile(context).delete() + nativeLogFile(context).delete() + uiLogcatFile(context).delete() + xemuLogcatFile(context).delete() + } + + inline fun d(tag: String, message: () -> String) { + if (!enabled) { + return + } + val text = message() + Log.d(tag, text) + appendUiLine("D", tag, text) + } + + inline fun i(tag: String, message: () -> String) { + if (!enabled) { + return + } + val text = message() + Log.i(tag, text) + appendUiLine("I", tag, text) + } + + inline fun w(tag: String, message: () -> String) { + if (!enabled) { + return + } + val text = message() + Log.w(tag, text) + appendUiLine("W", tag, text) + } + + inline fun e(tag: String, throwable: Throwable? = null, message: () -> String) { + val text = message() + if (throwable != null) { + Log.e(tag, text, throwable) + } else { + Log.e(tag, text) + } + if (enabled) { + appendUiLine("E", tag, text, throwable) + } + } + + fun nativeLogFile(context: Context): File { + return File(logDir(context), NATIVE_LOG_FILE_NAME) + } + + @PublishedApi + internal fun appendUiLine( + level: String, + tag: String, + message: String, + throwable: Throwable? = null, + ) { + val context = appContext ?: return + writerExecutor.execute { + try { + val file = uiLogFile(context) + file.parentFile?.mkdirs() + trimFileIfNeeded(file) + file.appendText( + buildString { + append(timestamp()) + append(' ') + append(level) + append('/') + append(tag) + append(": ") + appendLine(message) + if (throwable != null) { + appendLine(stackTraceFor(throwable)) + } + }, + Charsets.UTF_8 + ) + } catch (_: Exception) { + } + } + } + + private fun uiLogFile(context: Context): File { + return File(logDir(context), UI_LOG_FILE_NAME) + } + + private fun uiLogcatFile(context: Context): File { + return File(logDir(context), UI_LOGCAT_FILE_NAME) + } + + private fun xemuLogcatFile(context: Context): File { + return File(logDir(context), XEMU_LOGCAT_FILE_NAME) + } + + private fun currentProcessLogcatFile(context: Context): File { + val processName = runCatching { + File("/proc/self/cmdline").readText().trim('\u0000', ' ', '\n') + }.getOrDefault("") + return if (processName.endsWith(":xemu")) { + xemuLogcatFile(context) + } else { + uiLogcatFile(context) + } + } + + private fun logDir(context: Context): File { + return File(context.filesDir, LOG_DIR) + } + + private fun trimFileIfNeeded(file: File) { + if (file.isFile && file.length() > MAX_LOG_BYTES) { + file.writeText("") + } + } + + private fun timestamp(): String { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date()) + } + + private fun stackTraceFor(throwable: Throwable): String { + return StringWriter().also { writer -> + PrintWriter(writer).use { printer -> + throwable.printStackTrace(printer) + } + }.toString().trimEnd() + } + + private fun ensureLogcatCaptureState(context: Context) { + if (!enabled) { + stopLogcatCapture() + return + } + + val targetFile = currentProcessLogcatFile(context) + if (activeLogcatPath == targetFile.absolutePath && logcatProcess != null) { + return + } + + stopLogcatCapture() + startLogcatCapture(targetFile) + } + + private fun startLogcatCapture(targetFile: File) { + try { + targetFile.parentFile?.mkdirs() + trimFileIfNeeded(targetFile) + val process = ProcessBuilder( + "logcat", + "-T", + "1", + "-v", + "threadtime", + "--pid=${android.os.Process.myPid()}", + ) + .redirectErrorStream(true) + .start() + + val thread = Thread({ + try { + process.inputStream.bufferedReader().use { reader -> + FileOutputStream(targetFile, true).bufferedWriter(Charsets.UTF_8).use { writer -> + while (true) { + val line = reader.readLine() ?: break + writer.appendLine(line) + writer.flush() + } + } + } + } catch (_: Exception) { + } + }, "x1box-logcat-capture").apply { + isDaemon = true + start() + } + + logcatProcess = process + logcatThread = thread + activeLogcatPath = targetFile.absolutePath + } catch (error: Exception) { + Log.w(TAG, "Failed to start logcat capture", error) + } + } + + private fun stopLogcatCapture() { + logcatProcess?.destroy() + logcatProcess = null + logcatThread?.interrupt() + logcatThread = null + activeLogcatPath = null + } +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt index 7e956b6693..d40ce049aa 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/GameLibraryActivity.kt @@ -43,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" + const val EXTRA_INITIAL_ORIENTATION = + "com.izzy2lost.x1box.extra.INITIAL_ORIENTATION" private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12 private const val TOTAL_SNAPSHOT_SLOTS = 10 private const val XDVDFS_SECTOR_SIZE = 2048L @@ -101,7 +103,12 @@ class GameLibraryActivity : AppCompatActivity() { private val coverIndex = ConcurrentHashMap() private val coverCollapsedIndex = ConcurrentHashMap() private val coverEntries = ArrayList() + private val coverIndexLock = Any() + private val discFormatCacheLock = Any() + private val discFormatCache = HashMap() @Volatile private var coverIndexLoaded = false + @Volatile private var discFormatCacheLoaded = false + @Volatile private var discFormatCacheDirty = false private lateinit var loadingSpinner: ProgressBar private lateinit var loadingText: TextView @@ -148,6 +155,7 @@ class GameLibraryActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyInitialOrientationFromIntent() OrientationLocker(this).enable() setContentView(R.layout.activity_game_library) EdgeToEdgeHelper.enable(this) @@ -188,7 +196,11 @@ class GameLibraryActivity : AppCompatActivity() { launchDashboard() } btnSettings.setOnClickListener { - startActivity(Intent(this, SettingsActivity::class.java)) + startActivity( + Intent(this, SettingsActivity::class.java).apply { + putExtra(SettingsActivity.EXTRA_INITIAL_ORIENTATION, requestedOrientation) + } + ) } btnSnapshots.setOnClickListener { showSnapshotStartupPicker() @@ -219,6 +231,15 @@ class GameLibraryActivity : AppCompatActivity() { loadGames() } + private fun applyInitialOrientationFromIntent() { + val initialOrientation = intent.getIntExtra(EXTRA_INITIAL_ORIENTATION, Int.MIN_VALUE) + if (initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || + initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + ) { + requestedOrientation = initialOrientation + } + } + private fun tryRestartLastGameFromIntent(): Boolean { if (!intent.getBooleanExtra(EXTRA_RESTART_LAST_GAME, false)) { return false @@ -539,6 +560,7 @@ class GameLibraryActivity : AppCompatActivity() { val generation = ++scanGeneration Thread { + loadDiscFormatCacheIfNeeded() val games = scanFolderForGames(readyFolderUri) runOnUiThread { if (generation != scanGeneration) { @@ -813,47 +835,52 @@ class GameLibraryActivity : AppCompatActivity() { if (coverIndexLoaded) { return } - try { - val lines = assets.open("X1_Covers.txt").bufferedReader().use { it.readLines() } - val seenEntries = HashSet() - for (line in lines) { - val fileName = line.trim() - if (fileName.isEmpty() || !fileName.endsWith(".png", ignoreCase = true)) { - continue - } - val gameName = fileName.removeSuffix(".png").trim() - val encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20") - val url = coverRepoBaseUrl + encoded - - val exactKey = normalizeCoverKey(gameName) - val strippedKey = stripTrailingRegion(exactKey) - if (exactKey.isNotEmpty()) { - coverIndex.putIfAbsent(exactKey, url) - } - if (strippedKey.isNotEmpty()) { - coverIndex.putIfAbsent(strippedKey, url) - } - val canonical = if (strippedKey.isNotEmpty()) strippedKey else exactKey - val collapsed = collapseCoverKey(canonical) - if (collapsed.isNotEmpty()) { - coverCollapsedIndex.putIfAbsent(collapsed, url) - } - if (canonical.isNotEmpty() && seenEntries.add("$canonical|$url")) { - val tokens = tokenizeCoverKey(canonical) - coverEntries.add( - CoverEntry( - collapsed = collapsed, - tokens = tokens, - numericTokens = tokens.filterTo(HashSet()) { token -> token.any(Char::isDigit) }, - url = url - ) - ) - } + synchronized(coverIndexLock) { + if (coverIndexLoaded) { + return } - } catch (_: Exception) { - // Keep empty index; grid will show placeholders if the asset is unavailable. + try { + val lines = assets.open("X1_Covers.txt").bufferedReader().use { it.readLines() } + val seenEntries = HashSet() + for (line in lines) { + val fileName = line.trim() + if (fileName.isEmpty() || !fileName.endsWith(".png", ignoreCase = true)) { + continue + } + val gameName = fileName.removeSuffix(".png").trim() + val encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20") + val url = coverRepoBaseUrl + encoded + + val exactKey = normalizeCoverKey(gameName) + val strippedKey = stripTrailingRegion(exactKey) + if (exactKey.isNotEmpty()) { + coverIndex.putIfAbsent(exactKey, url) + } + if (strippedKey.isNotEmpty()) { + coverIndex.putIfAbsent(strippedKey, url) + } + val canonical = if (strippedKey.isNotEmpty()) strippedKey else exactKey + val collapsed = collapseCoverKey(canonical) + if (collapsed.isNotEmpty()) { + coverCollapsedIndex.putIfAbsent(collapsed, url) + } + if (canonical.isNotEmpty() && seenEntries.add("$canonical|$url")) { + val tokens = tokenizeCoverKey(canonical) + coverEntries.add( + CoverEntry( + collapsed = collapsed, + tokens = tokens, + numericTokens = tokens.filterTo(HashSet()) { token -> token.any(Char::isDigit) }, + url = url + ) + ) + } + } + } catch (_: Exception) { + // Keep empty index; grid will show placeholders if the asset is unavailable. + } + coverIndexLoaded = true } - coverIndexLoaded = true } private fun normalizeLookupTitle(input: String): String { @@ -1249,6 +1276,7 @@ class GameLibraryActivity : AppCompatActivity() { stack.add(root to "") val games = ArrayList() + val seenDiscFormatKeys = HashSet() while (stack.isNotEmpty()) { val (node, prefix) = stack.removeLast() val files = try { @@ -1265,22 +1293,128 @@ class GameLibraryActivity : AppCompatActivity() { if (!child.isFile || !isSupportedGame(name)) { continue } + val sizeBytes = child.length() games.add( GameEntry( title = toGameTitle(name), uri = child.uri, relativePath = prefix + name, - sizeBytes = child.length(), - discImageFormat = detectDiscImageFormat(child.uri, name), + sizeBytes = sizeBytes, + discImageFormat = resolveDiscImageFormat(child.uri, name, sizeBytes, seenDiscFormatKeys), ) ) } } games.sortBy { it.title.lowercase(Locale.ROOT) } + persistDiscFormatCache(seenDiscFormatKeys) return games } + private fun discFormatCacheFile(): File = File(filesDir, "disc_format_cache.tsv") + + private fun loadDiscFormatCacheIfNeeded() { + if (discFormatCacheLoaded) { + return + } + synchronized(discFormatCacheLock) { + if (discFormatCacheLoaded) { + return + } + val cacheFile = discFormatCacheFile() + if (cacheFile.isFile) { + runCatching { + cacheFile.forEachLine(Charsets.UTF_8) { line -> + val split = line.lastIndexOf('\t') + if (split <= 0 || split >= line.lastIndex) { + return@forEachLine + } + val key = line.substring(0, split) + val formatName = line.substring(split + 1) + runCatching { DiscImageFormat.valueOf(formatName) } + .getOrNull() + ?.let { format -> discFormatCache[key] = format } + } + } + } + discFormatCacheLoaded = true + } + } + + private fun persistDiscFormatCache(seenKeys: Set) { + synchronized(discFormatCacheLock) { + if (!discFormatCacheLoaded) { + return + } + + var changed = discFormatCacheDirty + val iterator = discFormatCache.keys.iterator() + while (iterator.hasNext()) { + if (iterator.next() !in seenKeys) { + iterator.remove() + changed = true + } + } + + if (!changed) { + return + } + + val cacheFile = discFormatCacheFile() + if (discFormatCache.isEmpty()) { + cacheFile.delete() + discFormatCacheDirty = false + return + } + + runCatching { + cacheFile.parentFile?.mkdirs() + cacheFile.bufferedWriter(Charsets.UTF_8).use { writer -> + discFormatCache.entries.forEach { (key, format) -> + writer.append(key) + writer.append('\t') + writer.append(format.name) + writer.append('\n') + } + } + discFormatCacheDirty = false + } + } + } + + private fun discFormatCacheKey(uri: Uri, sizeBytes: Long): String = + uri.toString() + "\t" + sizeBytes + + private fun resolveDiscImageFormat( + uri: Uri, + fileName: String, + sizeBytes: Long, + seenKeys: MutableSet, + ): DiscImageFormat { + val lower = fileName.lowercase(Locale.ROOT) + if (!isDiscImageFormatDetectable(lower)) { + return DiscImageFormat.OTHER + } + + val cacheKey = discFormatCacheKey(uri, sizeBytes) + seenKeys.add(cacheKey) + + synchronized(discFormatCacheLock) { + discFormatCache[cacheKey]?.let { cached -> + return cached + } + } + + val detected = detectDiscImageFormat(uri, fileName) + synchronized(discFormatCacheLock) { + if (discFormatCache[cacheKey] != detected) { + discFormatCache[cacheKey] = detected + discFormatCacheDirty = true + } + } + return detected + } + private fun detectDiscImageFormat(uri: Uri, fileName: String): DiscImageFormat { val lower = fileName.lowercase(Locale.ROOT) if (!isDiscImageFormatDetectable(lower)) { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt index 037fd496ed..7ac92141d9 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle -import android.util.Log import android.widget.Toast import java.io.File @@ -15,6 +14,7 @@ class LauncherActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + DebugLog.initialize(this) OrientationLocker(this).enable() val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE) @@ -113,16 +113,16 @@ class LauncherActivity : Activity() { .commit() if (hasMcpx && hasFlash && hasHdd) { - Log.i(TAG, "Frontend launch resolved via ${frontendLaunch.source}") + DebugLog.i(TAG) { "Frontend launch resolved via ${frontendLaunch.source}" } startActivity(Intent(this, MainActivity::class.java)) finish() return } - Log.i(TAG, "Frontend launch queued, but core setup is incomplete") + DebugLog.i(TAG) { "Frontend launch queued, but core setup is incomplete" } Toast.makeText(this, R.string.frontend_launch_setup_required, Toast.LENGTH_SHORT).show() } else if (hasExternalLaunchPayload(intent)) { - Log.w(TAG, "Frontend intent received but no accessible game target was resolved") + DebugLog.w(TAG) { "Frontend intent received but no accessible game target was resolved" } Toast.makeText(this, R.string.frontend_launch_unresolved, Toast.LENGTH_LONG).show() } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt index 6f0c25a476..1878a0f7ce 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt @@ -8,7 +8,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Process -import android.util.Log import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent @@ -78,6 +77,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + DebugLog.initialize(this) OrientationLocker(this, landscapeOnly = true).enable() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) val requestedSlot = intent?.getIntExtra(EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, 0) ?: 0 @@ -265,9 +265,9 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { 0, // nhats 0 // nballs ) - android.util.Log.d("MainActivity", "Virtual controller registered successfully") + DebugLog.d("MainActivity") { "Virtual controller registered successfully" } } catch (e: Exception) { - android.util.Log.e("MainActivity", "Failed to register virtual controller: ${e.message}") + DebugLog.e("MainActivity", e) { "Failed to register virtual controller: ${e.message}" } } } @@ -329,7 +329,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } override fun onDestroy() { - Log.i(TAG, "onDestroy()") + DebugLog.i(TAG) { "onDestroy()" } swipeUpGestureRecognizer.reset() inGameMenuDialog?.dismiss() inGameMenuDialog = null @@ -343,7 +343,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { try { org.libsdl.app.SDLControllerManager.nativeRemoveJoystick(-2) } catch (e: Exception) { - android.util.Log.e("MainActivity", "Failed to unregister virtual controller: ${e.message}") + DebugLog.e("MainActivity", e) { "Failed to unregister virtual controller: ${e.message}" } } inputManager?.unregisterInputDeviceListener(this) @@ -352,17 +352,17 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } override fun onUserLeaveHint() { - Log.i(TAG, "onUserLeaveHint()") + DebugLog.i(TAG) { "onUserLeaveHint()" } super.onUserLeaveHint() } override fun onTrimMemory(level: Int) { - Log.i(TAG, "onTrimMemory(level=$level)") + DebugLog.i(TAG) { "onTrimMemory(level=$level)" } super.onTrimMemory(level) } override fun onLowMemory() { - Log.w(TAG, "onLowMemory()") + DebugLog.w(TAG) { "onLowMemory()" } super.onLowMemory() } @@ -803,6 +803,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { private fun exitToGameLibrary() { val intent = Intent(this, GameLibraryActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(GameLibraryActivity.EXTRA_INITIAL_ORIENTATION, requestedOrientation) } startActivity(intent) terminateXemuProcessSoon("exit to library") @@ -826,7 +827,7 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { } catch (_: InterruptedException) { Thread.currentThread().interrupt() } - Log.i(TAG, "Terminating :xemu process after $reason") + DebugLog.i(TAG) { "Terminating :xemu process after $reason" } Process.killProcess(Process.myPid()) }.start() } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt index c3dfb623b7..26ac67b0a1 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -31,6 +31,8 @@ import java.util.zip.ZipInputStream class SettingsActivity : AppCompatActivity() { companion object { + const val EXTRA_INITIAL_ORIENTATION = + "com.izzy2lost.x1box.extra.INITIAL_ORIENTATION" private const val PREF_ADVANCED_EXPERIMENTAL_EXPANDED = "settings_advanced_experimental_expanded" private const val PREF_INSIGNIA_SETUP_URI = "setting_insignia_setup_assistant_uri" private const val PREF_INSIGNIA_SETUP_NAME = "setting_insignia_setup_assistant_name" @@ -129,6 +131,7 @@ class SettingsActivity : AppCompatActivity() { private var isImportingDashboard = false private var isPreparingInsignia = false + private lateinit var switchDebugLogs: MaterialSwitch private lateinit var switchNetworkEnable: MaterialSwitch private lateinit var tvVulkanDriverName: TextView private lateinit var tvInsigniaStatus: TextView @@ -205,9 +208,17 @@ class SettingsActivity : AppCompatActivity() { launchInsigniaSetupAssistant(uri) } + private val exportDebugLogDocument = + registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? -> + uri ?: return@registerForActivityResult + exportDebugLog(uri) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyInitialOrientationFromIntent() OrientationLocker(this).enable() + DebugLog.initialize(this) setContentView(R.layout.activity_settings) EdgeToEdgeHelper.enable(this) EdgeToEdgeHelper.applySystemBarPadding(findViewById(R.id.settings_scroll)) @@ -230,10 +241,12 @@ class SettingsActivity : AppCompatActivity() { val switchFpu = findViewById(R.id.switch_hard_fpu) val switchVsync = findViewById(R.id.switch_vsync) val switchSkipBootAnim = findViewById(R.id.switch_skip_boot_anim) + switchDebugLogs = findViewById(R.id.switch_debug_logs) val toggleAudioDriver = findViewById(R.id.toggle_audio_driver) val btnSave = findViewById(R.id.btn_settings_save) val btnRedoSetup = findViewById(R.id.btn_redo_setup_wizard) val btnClearCache = findViewById(R.id.btn_clear_system_cache) + val btnExportDebugLog = findViewById(R.id.btn_export_debug_log) val btnInitializeRetailHdd = findViewById(R.id.btn_initialize_retail_hdd) switchNetworkEnable = findViewById(R.id.switch_network_enable) tvInsigniaStatus = findViewById(R.id.tv_insignia_status) @@ -317,9 +330,11 @@ class SettingsActivity : AppCompatActivity() { switchHrtf.isChecked = prefs.getBoolean("setting_hrtf", true) switchShaders.isChecked = prefs.getBoolean("setting_cache_shaders", true) switchFpu.isChecked = prefs.getBoolean("setting_hard_fpu", true) - switchVsync.isChecked = prefs.getBoolean("setting_vsync", true) + switchVsync.isChecked = prefs.getBoolean("setting_vsync", false) switchSkipBootAnim.isChecked = prefs.getBoolean("setting_skip_boot_anim", false) + switchDebugLogs.isChecked = + prefs.getBoolean(DebugLog.PREF_ENABLED, false) switchNetworkEnable.isChecked = prefs.getBoolean("setting_network_enable", false) @@ -409,6 +424,8 @@ class SettingsActivity : AppCompatActivity() { R.id.btn_filtering_nearest -> "nearest" else -> "linear" } + val wasDebugLoggingEnabled = prefs.getBoolean(DebugLog.PREF_ENABLED, false) + val enableDebugLogs = switchDebugLogs.isChecked val edit = prefs.edit() .putInt("setting_display_mode", selectedDisplayMode) @@ -422,6 +439,7 @@ class SettingsActivity : AppCompatActivity() { .putBoolean("setting_hard_fpu", switchFpu.isChecked) .putBoolean("setting_vsync", switchVsync.isChecked) .putBoolean("setting_skip_boot_anim", switchSkipBootAnim.isChecked) + .putBoolean(DebugLog.PREF_ENABLED, enableDebugLogs) .putBoolean("setting_network_enable", switchNetworkEnable.isChecked) .putString("setting_audio_driver", selectedAudioDriver) .putString("setting_filtering", selectedFiltering) @@ -437,6 +455,11 @@ class SettingsActivity : AppCompatActivity() { } edit.apply() + DebugLog.setEnabled( + context = this@SettingsActivity, + value = enableDebugLogs, + resetLogs = enableDebugLogs && !wasDebugLoggingEnabled + ) return applyEepromEdits() } @@ -444,6 +467,13 @@ class SettingsActivity : AppCompatActivity() { btnClearCache.setOnClickListener { showClearCacheConfirmation() } + btnExportDebugLog.setOnClickListener { + if (!DebugLog.hasAnyLog(this)) { + Toast.makeText(this, R.string.settings_export_debug_log_empty, Toast.LENGTH_LONG).show() + return@setOnClickListener + } + exportDebugLogDocument.launch(DebugLog.exportDefaultFileName()) + } btnInitializeRetailHdd.setOnClickListener { showInitializeHddLayoutPicker(btnInitializeRetailHdd) @@ -456,6 +486,15 @@ class SettingsActivity : AppCompatActivity() { } } + private fun applyInitialOrientationFromIntent() { + val initialOrientation = intent.getIntExtra(EXTRA_INITIAL_ORIENTATION, Int.MIN_VALUE) + if (initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || + initialOrientation == android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + ) { + requestedOrientation = initialOrientation + } + } + private fun setAdvancedExperimentalExpanded(expanded: Boolean) { layoutAdvancedExperimentalContent.visibility = if (expanded) View.VISIBLE else View.GONE btnToggleAdvancedExperimental.text = getString( @@ -468,6 +507,21 @@ class SettingsActivity : AppCompatActivity() { prefs.edit().putBoolean(PREF_ADVANCED_EXPERIMENTAL_EXPANDED, expanded).apply() } + private fun exportDebugLog(uri: Uri) { + try { + contentResolver.openOutputStream(uri, "w")?.use { stream -> + DebugLog.exportCombined(this, stream) + } ?: throw IOException("Could not open the selected export location.") + Toast.makeText(this, R.string.settings_export_debug_log_success, Toast.LENGTH_LONG).show() + } catch (error: Exception) { + Toast.makeText( + this, + getString(R.string.settings_export_debug_log_failed, error.message ?: "unknown error"), + Toast.LENGTH_LONG + ).show() + } + } + private fun openExternalLink(url: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { addCategory(Intent.CATEGORY_BROWSABLE) diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt index 77d892ab54..c2ec900fd3 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SetupWizardActivity.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.text.format.Formatter -import android.util.Log import android.view.View import android.widget.TextView import android.widget.Toast @@ -152,6 +151,7 @@ class SetupWizardActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + DebugLog.initialize(this) OrientationLocker(this).enable() mcpxPath = loadValidatedLocalPath("mcpxPath", "mcpxUri", ::isSavedMcpxFileValid) @@ -391,7 +391,7 @@ class SetupWizardActivity : AppCompatActivity() { val base = getExternalFilesDir(null) ?: filesDir val dir = File(base, "x1box") if (!dir.exists() && !dir.mkdirs()) { - Log.e("xemu-android", "Failed to create ${dir.absolutePath}") + DebugLog.e("xemu-android") { "Failed to create ${dir.absolutePath}" } return null } val target = File(dir, destName) @@ -403,7 +403,7 @@ class SetupWizardActivity : AppCompatActivity() { } ?: return null target.absolutePath } catch (e: IOException) { - Log.e("xemu-android", "Copy failed for $destName", e) + DebugLog.e("xemu-android", e) { "Copy failed for $destName" } null } } diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index d8f6b9e7bb..0c30c31dbd 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -798,6 +798,30 @@ android:textAppearance="@style/TextAppearance.Material3.LabelLarge" android:textColor="@color/xemu_green_light" /> + + + + + + Boot compatibility: this import did not include C:\\xboxdash.xbe, so %1$s will also be copied as xboxdash.xbe. Warning: no obvious dashboard XBE was found anywhere under C:. Boot Dashboard may still show the service screen. Maintenance + Enable Debug Logs + Writes extra UI and emulator bootstrap logs only while enabled. Turn this on before reproducing a problem, then export the log. + Export Debug Log + No debug log has been captured yet. + Debug log exported + Failed to export debug log: %1$s Clear Cache Clear emulator cache files now? This removes shader and app cache data, but keeps your games, saves, and HDD image. Clear Cache diff --git a/ui/xemu.c b/ui/xemu.c index c9681519a1..41c683bb06 100644 --- a/ui/xemu.c +++ b/ui/xemu.c @@ -183,6 +183,10 @@ static bool sdl2_gl_has_extension(const char *ext_list, const char *ext) static void android_log_gl_error(const char *stage) { + if (!android_render_logs_enabled()) { + return; + } + GLenum err; bool logged = false; while ((err = glGetError()) != GL_NO_ERROR) { @@ -1377,7 +1381,7 @@ void xemu_android_display_loop(void) } #ifdef __ANDROID__ xemu_android_refresh_frame_limit_from_env(); - SDL_GL_SetSwapInterval(1); + SDL_GL_SetSwapInterval(g_config.display.window.vsync ? 1 : 0); xemu_hud_init(m_window, m_context); #endif tcg_register_init_ctx();