added log option and fix ui switching orientation problems.. make ui faster also vsync option fix

This commit is contained in:
izzy2lost
2026-03-17 04:18:16 -04:00
parent 4866875524
commit a37139236f
13 changed files with 762 additions and 72 deletions
-2
View File
@@ -53,12 +53,10 @@
<activity
android:name=".GameLibraryActivity"
android:screenOrientation="portrait"
android:exported="false" />
<activity
android:name=".SettingsActivity"
android:screenOrientation="portrait"
android:exported="false" />
</application>
+152 -2
View File
@@ -13,9 +13,11 @@
#include <atomic>
#include <cctype>
#include <cstdint>
#include <cstdarg>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <cstring>
#include <fstream>
#include <string>
@@ -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<bool> g_qemu_init_started{false};
static std::atomic<bool> 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<jstring>(
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<off_t>(-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(),
@@ -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;
@@ -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}" }
}
}
@@ -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
}
}
@@ -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<String, String>()
private val coverCollapsedIndex = ConcurrentHashMap<String, String>()
private val coverEntries = ArrayList<CoverEntry>()
private val coverIndexLock = Any()
private val discFormatCacheLock = Any()
private val discFormatCache = HashMap<String, DiscImageFormat>()
@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<String>()
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<String>()
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<GameEntry>()
val seenDiscFormatKeys = HashSet<String>()
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<String>) {
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<String>,
): 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)) {
@@ -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()
}
@@ -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()
}
@@ -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<MaterialSwitch>(R.id.switch_hard_fpu)
val switchVsync = findViewById<MaterialSwitch>(R.id.switch_vsync)
val switchSkipBootAnim = findViewById<MaterialSwitch>(R.id.switch_skip_boot_anim)
switchDebugLogs = findViewById(R.id.switch_debug_logs)
val toggleAudioDriver = findViewById<MaterialButtonToggleGroup>(R.id.toggle_audio_driver)
val btnSave = findViewById<MaterialButton>(R.id.btn_settings_save)
val btnRedoSetup = findViewById<MaterialButton>(R.id.btn_redo_setup_wizard)
val btnClearCache = findViewById<MaterialButton>(R.id.btn_clear_system_cache)
val btnExportDebugLog = findViewById<MaterialButton>(R.id.btn_export_debug_log)
val btnInitializeRetailHdd = findViewById<MaterialButton>(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)
@@ -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
}
}
@@ -798,6 +798,30 @@
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="@color/xemu_green_light" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_debug_logs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_debug_logs_enable"
android:textColor="@color/xemu_text_muted" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginBottom="10dp"
android:text="@string/settings_debug_logs_hint"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_export_debug_log"
style="@style/Widget.Xemu.Button.Outlined.Pill"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/settings_export_debug_log_action" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_clear_system_cache"
style="@style/Widget.Xemu.Button.Outlined.Pill"
@@ -268,6 +268,12 @@
<string name="settings_dashboard_import_boot_alias_note">Boot compatibility: this import did not include C:\\xboxdash.xbe, so %1$s will also be copied as xboxdash.xbe.</string>
<string name="settings_dashboard_import_boot_missing_note">Warning: no obvious dashboard XBE was found anywhere under C:. Boot Dashboard may still show the service screen.</string>
<string name="settings_section_maintenance">Maintenance</string>
<string name="settings_debug_logs_enable">Enable Debug Logs</string>
<string name="settings_debug_logs_hint">Writes extra UI and emulator bootstrap logs only while enabled. Turn this on before reproducing a problem, then export the log.</string>
<string name="settings_export_debug_log_action">Export Debug Log</string>
<string name="settings_export_debug_log_empty">No debug log has been captured yet.</string>
<string name="settings_export_debug_log_success">Debug log exported</string>
<string name="settings_export_debug_log_failed">Failed to export debug log: %1$s</string>
<string name="settings_clear_cache_title">Clear Cache</string>
<string name="settings_clear_cache_message">Clear emulator cache files now? This removes shader and app cache data, but keeps your games, saves, and HDD image.</string>
<string name="settings_clear_cache_action">Clear Cache</string>
+5 -1
View File
@@ -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();