Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
25d8994
[Kernel] Fire timers set with a past/epoch absolute due time
rfandango Aug 10, 2026
b10104c
[Kernel] Close the lost-wake races behind targeted cooperative signal…
rfandango Aug 10, 2026
a9cd34d
[Kernel] Warn when the repoll backstop rescues waits whose wakes were…
rfandango Aug 10, 2026
47253f3
[Android] Keep add-on content out of the library, but not arcade titles
rfandango Aug 11, 2026
be3055a
[Android] Style the pause menu as a guest panel and make it controlle…
rfandango Aug 11, 2026
37ea4d7
[Android] Skip the startup update check in debug builds
rfandango Aug 11, 2026
bae7ebb
[Android] Center the pause menu on screen
rfandango Aug 11, 2026
d67d207
[Android] Put the on-screen controller behind a setting seeded from c…
rfandango Aug 11, 2026
1558907
[Vulkan] Give Turnip the UBWC flag hint on One UI and HyperOS
rfandango Aug 11, 2026
394a612
[Android] Drop input events that arrive before the input driver exists
rfandango Aug 11, 2026
3b931db
[Android] Wait for the touch-overlay setting before mounting the overlay
rfandango Aug 11, 2026
db6bb0f
[Kernel] Log the dirty-disc error and exit without running static des…
rfandango Aug 11, 2026
1d14566
[Vulkan] Log the vendor OS probe behind the Turnip UBWC flag hint
rfandango Aug 11, 2026
a3af2b5
[Android] Install a mandatory-install title's content straight from i…
rfandango Aug 11, 2026
a6f6a29
[Android] Offer to install a disc's content when its game is launched
rfandango Aug 11, 2026
9a13c59
[Vulkan] Count only resolves that stored into the texture as covering it
rfandango Aug 12, 2026
b177a12
[Vulkan] Keep promoted resolve destinations in GENERAL and fence thei…
rfandango Aug 12, 2026
9791f63
[APU] Build queue depth from spare credits and refund unanswered pumps
rfandango Aug 12, 2026
e0137c9
[APU] Report pump durations through an ADPF hint session to hold cloc…
rfandango Aug 12, 2026
2f55221
[APU] Bend playback rate toward 0.9x as the audio queue drains, never…
rfandango Aug 12, 2026
0feaeed
[CPU] Skip VECTOR_DENORMFLUSH when FPCR.FZ already flushes inputs
rfandango Aug 12, 2026
0fd8002
[CPU] Lower vspltw as a broadcast swizzle, not an extract-splat round…
rfandango Aug 12, 2026
6ff74bb
[CPU] Add dump_functions_at for targeted per-function translation dumps
rfandango Aug 12, 2026
56d6bff
[APU] Stop the audio sinks while paused and resume them at full rate
rfandango Aug 12, 2026
e12b3ce
[Android] Refresh the bundled config template instead of writing it once
rfandango Aug 12, 2026
411565d
[GPU] Default clear_memory_page_state to off in the bundled config
rfandango Aug 12, 2026
d5ee7bd
[GPU] Upload the readable part of a range and keep decommitted pages …
rfandango Aug 13, 2026
70163b4
[APU] Refill the audio buffer from its real depth, slow playback only…
rfandango Aug 13, 2026
162c86e
[Vulkan] Write sampled-image descriptors with the layout the texture …
rfandango Aug 13, 2026
8e48dd4
[Vulkan] Bind the pipeline captured at draw record time, dropping unr…
rfandango Aug 13, 2026
036fedb
[Vulkan] Resolve the descriptor image layout from the view actually b…
rfandango Aug 13, 2026
68e78ca
[Vulkan] Give transient storage-image descriptors a pool of their own…
rfandango Aug 13, 2026
ece7166
[GPU] Read shader storage from the start of the file, not from its end
rfandango Aug 13, 2026
368cece
[GPU] Signal shader storage completion when it completes, and gate pi…
rfandango Aug 13, 2026
ff972fe
[Android] Keep the startup update check quiet while it is in cooldown
rfandango Aug 13, 2026
d434ef5
[CPU] Select the right lane halves when folding a constant halfword p…
rfandango Aug 13, 2026
0ac798c
[CPU] Add a cvar-gated log of guest function entries, returns and fields
rfandango Aug 13, 2026
465a22a
[Kernel] Refuse guest sockets while the XNet session layer cannot rea…
rfandango Aug 13, 2026
7b33819
[GPU] Keep in-pass EDRAM resolves off by default, on for the TDU titles
rfandango Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions app/src/main/java/xendroid/compose/EmulatorHostActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
Expand All @@ -63,6 +60,12 @@ import xendroid.compose.ui.disc.DiscSwapPanel
import xendroid.compose.ui.keyboard.GuestKeyboardPanel
import xendroid.compose.ui.keyboard.clampToUtf16Units
import xendroid.compose.ui.messagebox.GuestMessageBoxPanel
import xendroid.compose.ui.pause.PAUSE_OPTION_COUNT
import xendroid.compose.settings.ConfigStore
import xendroid.compose.ui.pause.PAUSE_OPTION_QUIT
import xendroid.compose.ui.pause.PAUSE_OPTION_TOUCH_OVERLAY
import xendroid.compose.ui.pause.PAUSE_OPTION_RESUME
import xendroid.compose.ui.pause.PauseMenuPanel
import xendroid.compose.ui.theme.xendroidTheme
import xendroid.compose.gamepad.GamepadConfigDto
import xendroid.compose.gamepad.GamepadController
Expand All @@ -71,7 +74,6 @@ import xendroid.compose.gamepad.Kc
import xendroid.compose.gamepad.rememberAutoHide
import xendroid.compose.data.GameButtons
import xendroid.compose.data.KeymapStore
import xendroid.compose.settings.ConfigStore

/**
* The :emu emulator host (separate process; see manifest). Reads game_uri from the Intent,
Expand Down Expand Up @@ -120,6 +122,10 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
private var hapticsEnabled = false
private val bootedState = mutableStateOf(false)
private val showFpsOverlay = mutableStateOf(false) // Display|show_debug_overlay (native TOML config)
// Null until the first post-boot poll reads HID|show_touch_overlay. Mounting the overlay
// on an assumed value and unmounting a tick later fires its release-all teardown while
// the core is still booting.
private val showTouchOverlay = mutableStateOf<Boolean?>(null)
private val menuOpenState = mutableStateOf(false)
private val keyboardRequestState =
mutableStateOf<Emulator.KeyboardRequest?>(null)
Expand Down Expand Up @@ -257,7 +263,8 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
val alpha by animateFloatAsState(
if (visible) cfg.globals.opacity else 0f, tween(500), label = "padAlpha")

val overlayActive = booted && cfg.globals.enabled
val padVisible = showTouchOverlay.value == true
val overlayActive = booted && cfg.globals.enabled && padVisible
// onStart/onStop co-own the sampler thread: no PixelCopy polling while backgrounded.
DisposableEffect(overlayActive) {
overlayWantsBrightness = overlayActive
Expand All @@ -274,7 +281,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
// Stay MOUNTED whenever enabled (alpha drives only the DRAW): the pointerInput
// must keep receiving touches so the auto-hide wake tap fires, and so a held
// control is never unmounted mid-press (stuck).
if (booted && cfg.globals.enabled) {
if (booted && cfg.globals.enabled && padVisible) {
GamepadOverlay(
controls = controls,
opacity = alpha,
Expand All @@ -295,6 +302,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!booted) return@LaunchedEffect
while (isActive) {
showFpsOverlay.value = session.showDebugOverlayEnabled()
showTouchOverlay.value = session.showTouchOverlayEnabled()
delay(1000)
}
}
Expand Down Expand Up @@ -402,17 +410,19 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
messageBoxRequestState.value?.let { answerMessageBox(it.activeButton) }
}
BackHandler(enabled = !menuOpen && !keyboardOpen && !discOpen && !messageBoxOpen) {
panelSelectedState.intValue = PAUSE_OPTION_RESUME
menuOpenState.value = true
if (session.booted) session.pause()
}
BackHandler(enabled = menuOpen) { closeMenuAndResume() }
if (menuOpen) xendroidTheme {
AlertDialog(
onDismissRequest = {
menuOpenState.value = false
if (session.booted) session.resumeIfPaused()
},
title = { Text("Paused") },
confirmButton = { Button(onClick = { finish() }) { Text("Quit") } },
PauseMenuPanel(
selected = panelSelectedState.intValue,
touchOverlayShown = showTouchOverlay.value == true,
onToggleTouchOverlay = { toggleTouchOverlay() },
onResume = { closeMenuAndResume() },
onQuit = { finish() },
modifier = Modifier.fillMaxSize(),
)
}
}
Expand Down Expand Up @@ -688,9 +698,41 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback {
{ i -> if (i == 0) acceptKeyboard(keyboardTextState.value) else cancelKeyboard() },
{ cancelKeyboard() })
}
// Last: a guest prompt opened over the menu owns the input until it is answered.
if (menuOpenState.value) {
return PanelNav(PAUSE_OPTION_COUNT,
{ i ->
when (i) {
PAUSE_OPTION_QUIT -> finish()
PAUSE_OPTION_TOUCH_OVERLAY -> toggleTouchOverlay()
else -> closeMenuAndResume()
}
},
{ closeMenuAndResume() })
}
return null
}

/** Flips the live cvar for an immediate effect and persists it, leaving the menu open so
* the result is visible behind it. */
private fun toggleTouchOverlay() {
val next = showTouchOverlay.value != true
showTouchOverlay.value = next
session.setShowTouchOverlay(next)
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val handle = ConfigStore(applicationContext).openLive()
handle.putBool("HID", "show_touch_overlay", next)
handle.closeFile()
}.onFailure { Log.w(TAG, "persisting show_touch_overlay failed", it) }
}
}

private fun closeMenuAndResume() {
menuOpenState.value = false
if (session.booted) session.resumeIfPaused()
}

private fun isPanelKey(keyCode: Int): Boolean = when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT,
Expand Down
20 changes: 9 additions & 11 deletions app/src/main/java/xendroid/compose/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import xendroid.compose.ui.library.EXTRA_GAME_URI
import xendroid.compose.core.SessionLogs
import xendroid.compose.ui.AppNavHost
import xendroid.compose.ui.theme.xendroidTheme
import xendroid.compose.updater.CooldownDialog
import xendroid.compose.updater.getRemainingCooldown
import xendroid.compose.settings.ConfigStore
import xendroid.compose.settings.seedTouchOverlayDefault
import xendroid.compose.updater.LatestVersionDialog
import xendroid.compose.updater.UpdateDialog
import xendroid.compose.updater.UpdateResult
Expand Down Expand Up @@ -64,6 +64,8 @@ class MainActivity : ComponentActivity() {

// Pre-warm so settings doesn't pay the delay-load System.loadLibrary.
runCatching { EmulatorRuntime.ensureLoaded() }
// Needs the native config, so it follows ensureLoaded on this same thread.
runCatching { seedTouchOverlayDefault(appContext, ConfigStore(appContext)) }
}
}

Expand All @@ -78,9 +80,12 @@ class MainActivity : ComponentActivity() {

LaunchedEffect(Unit) {
if (frontendGame != null) return@LaunchedEffect
// A debug build's -debug versionName never matches a release tag, so the
// check always reports an update - to an APK whose .debug-suffixed package
// could not replace this install anyway.
if (BuildConfig.DEBUG) return@LaunchedEffect
if (!shouldCheckForUpdates(applicationContext)) {
Log.d("Updater", "Skipping update check (less than 5 minutes)")
updateResult = UpdateResult.Cooldown(getRemainingCooldown(applicationContext))
return@LaunchedEffect
}

Expand Down Expand Up @@ -115,14 +120,7 @@ class MainActivity : ComponentActivity() {
)
}

is UpdateResult.Cooldown -> {
CooldownDialog(
remainingMillis = result.remainingMillis,
onDismiss = { updateResult = null }
)
}

null -> {}
is UpdateResult.Cooldown, null -> {}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/xendroid/compose/core/EmulatorSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ class EmulatorSession {
/** Effective show_debug_overlay (global + per-game override). The override lands on the
* detached boot thread, so callers must POLL this after boot. */
fun showDebugOverlayEnabled(): Boolean = if (booted) core.show_debug_overlay_enabled() else false
fun showTouchOverlayEnabled(): Boolean = if (booted) core.show_touch_overlay_enabled() else true
fun setShowTouchOverlay(value: Boolean) { if (booted) core.set_show_touch_overlay(value) }

/** A pending guest text prompt, or null. Holds a dispatch thread until [keyboardSubmit]
* answers, so every shown panel must be answered. */
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/xendroid/compose/core/GameMetadataSource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,38 @@ class GameMetadataSource {
}
}

/** One installable package inside a disc image (see [listDiscContent]). */
data class DiscContent(
val innerPath: String,
val displayName: String,
val titleId: String?,
val contentType: Int,
val size: Long,
)

/** Packages a disc image carries under \content\ -- what a mandatory-install title
* (GTA V and friends) expects on the HDD before it will run. Empty for an ordinary
* disc. Off-main: walks the image. */
fun listDiscContent(path: String): List<DiscContent> {
val emu = EmulatorRuntime.emulator ?: return emptyList()
return try {
emu.list_disc_content(path).orEmpty().map { item ->
DiscContent(
innerPath = item.innerPath,
displayName = item.displayName ?: "",
titleId = item.titleId.takeIf { it != 0 }?.let { "%08X".format(it) },
contentType = item.contentType,
size = item.size,
)
}
} catch (t: RuntimeException) {
Log.w("GameMetadataSource", "disc content listing failed for $path", t)
emptyList()
} catch (t: LinkageError) {
warnMissingNative("list_disc_content", t); emptyList()
}
}

/** GOD container header read from a real path (title + icon + title id). */
fun readGodPath(path: String): GodMeta? {
val emu = EmulatorRuntime.emulator ?: return null
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/xendroid/compose/data/GameLibraryRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@ class GameLibraryRepository(
return Game(path, hit.name, fmt, hit.iconCacheName, hit.titleId, hit.mediaId,
hit.discNumber, hit.discCount)
}
// The type gate runs before the GOD probe, not just in the STFS branch below: add-on
// content (DLC, title updates, profiles, saves) is an STFS container too, so the GOD
// reader parses it happily and would publish it as a game. An unreadable header is not
// a rejection - it only means the type is unknown, so those still fall through.
val header = metadata.readContentHeader(path)
if (header != null && !ContentPaths.isLaunchableGameType(header.contentType)) return null
metadata.readGodPath(path)?.let { meta ->
val displayName = meta.name.ifEmpty { name }
val iconName = meta.iconPng?.let { iconCache.write(path, it) }
Expand All @@ -227,10 +233,8 @@ class GameLibraryRepository(
return Game(path, displayName, GameFormat.GOD, iconName, meta.titleId, meta.mediaId,
meta.discNumber, meta.discCount)
}
// GOD MUST be probed first (above): a GOD container also parses as a content package,
// so reaching here only after readGodPath fails keeps GOD out of this STFS branch.
val header = metadata.readContentHeader(path) ?: return null
if (!ContentPaths.isLaunchableGameType(header.contentType)) return null // add-on content, not a game
// GOD is probed first (above) because a GOD container also parses as a content package.
if (header == null) return null
val displayName = header.displayName.ifBlank { name }
val iconName = header.iconPng?.let { iconCache.write(path, it) }
metadataCache.put(path, displayName, iconName, signatureOf(child), header.titleId, null, GameFormat.STFS)
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/xendroid/compose/data/GameMetadataCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ class GameMetadataCache(cacheDir: File) {

companion object {
private const val TAG = "GameMetadataCache"
const val FILE_NAME = "game_metadata_v4.json"
// v5: v4 caches hold add-on content the deep scan published as games before the
// content-type gate; a hit would resurrect it without ever reclassifying.
const val FILE_NAME = "game_metadata_v5.json"

/**
* PURE, side-effect-free HIT/MISS decision (no SAF/JNI/IO) so it is unit-testable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ object SettingDescriptions {
"allow_plugins" to "Allows loading homebrew plugin/trainer XEX modules per title; experimental",
"android_message_box" to "Shows a game's own prompts (Xbox LIVE sign-in, save confirmations) as a dialog you answer. With this off the game is answered with whichever button it pre-selected",
"android_soft_keyboard" to "Pops up the Android keyboard when a game asks you to type (gamertags, save names). With this off the game is answered with its own default text",
"show_touch_overlay" to "Draw the on-screen controller while playing. Starts off when a physical controller was attached on first launch, on when none was",
"apply_patches" to "Applies user patch files to games when they load",
"apply_title_update" to "Applies installed title updates when launching a game",
"apu" to "Audio output backend to use (AAudio, OpenSL ES, or no audio)",
Expand All @@ -19,6 +20,7 @@ object SettingDescriptions {
"break_condition_truncate" to "Truncates the conditional-breakpoint register value to 32 bits before comparing",
"break_on_start" to "Breaks into the debugger as soon as the emulator starts",
"break_on_unimplemented_instructions" to "Breaks into the debugger (or crashes) when an unimplemented PowerPC instruction is hit",
"apu_pump_topup" to "Keep the audio buffer full by submitting ahead; turn off if a game's audio distorts",
"clear_memory_page_state" to "Refresh memory page state so GPU-written data is visible; off is slightly faster but may break rendering",
"clock_no_scaling" to "Bypasses guest clock scaling and pulls time straight from the host; ignores time scalar",
"clock_source_raw" to "Uses the raw CPU counter (CNTVCT) as the time source instead of OS clock calls",
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/xendroid/compose/settings/SettingsSchema.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ object SettingsSchema {
b("Kernel", "kernel_debug_monitor", "Kernel debug monitor", false),
)),

SettingsCategory("Controller", listOf(
b("HID", "show_touch_overlay", "Show on-screen controller", true),
)),

SettingsCategory("HID", listOf(
l("HID", "hid", "HID backend", "android",
"android" to "android", "nop" to "nop"),
Expand Down Expand Up @@ -236,6 +240,7 @@ object SettingsSchema {
)),

SettingsCategory("APU", listOf(
b("APU", "apu_pump_topup", "Audio pump top-up", true),
i("Console", "xmp_default_volume", "XMP default volume", 70, 0, 100),
b("APU", "ffmpeg_verbose", "FFmpeg verbose", false),
b("APU", "mute", "Mute", false),
Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/xendroid/compose/settings/TouchOverlayDefault.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package xendroid.compose.settings

import android.content.Context
import android.preference.PreferenceManager
import android.util.Log
import android.view.InputDevice

/** A gamepad or joystick is attached right now (virtual devices excluded, since the
* emulator's own injected device advertises gamepad sources). */
fun hasPhysicalController(): Boolean = InputDevice.getDeviceIds().any { id ->
val device = InputDevice.getDevice(id) ?: return@any false
val sources = device.sources
!device.isVirtual &&
(sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK)
}

/**
* Writes HID|show_touch_overlay once per install, from whether a controller was attached:
* with one, the overlay starts hidden, without one it starts shown. The cvar's own default
* cannot express that, and re-deriving it every launch would fight the user's own choice
* whenever a controller was plugged in or out. Call off the main thread, after the native
* library is loaded.
*/
fun seedTouchOverlayDefault(context: Context, store: ConfigStore) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (prefs.getBoolean(KEY_SEEDED, false)) return
val show = !hasPhysicalController()
runCatching {
val handle = store.openLive()
handle.putBool("HID", "show_touch_overlay", show)
handle.closeFile()
}.onFailure {
// Leave the flag unset so the next launch retries rather than silently keeping
// the cvar default.
Log.w("TouchOverlay", "seeding show_touch_overlay failed", it)
return
}
prefs.edit().putBoolean(KEY_SEEDED, true).apply()
}

private const val KEY_SEEDED = "touch_overlay_default_seeded"
14 changes: 12 additions & 2 deletions app/src/main/java/xendroid/compose/ui/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ fun AppNavHost(container: AppContainer) {
navigateOnce("${Routes.CONTENT_MANAGER}/$titleId?name=${Uri.encode(name)}")
},
onOpenInstallContent = { navigateOnce(Routes.INSTALL_CONTENT) },
onInstallFromDisc = { path ->
navigateOnce("${Routes.INSTALL_CONTENT}?src=" + Uri.encode(path))
},
)
}
composable(Routes.SETTINGS) { entry ->
Expand Down Expand Up @@ -165,10 +168,17 @@ fun AppNavHost(container: AppContainer) {
viewModel(factory = container.gameContentManagerViewModelFactory(titleId))
ContentManagerScreen(vm = vm, gameName = gameName, onBack = backStackEntry.backOnce(nav))
}
composable(Routes.INSTALL_CONTENT) { entry ->
composable(
"${Routes.INSTALL_CONTENT}?src={src}",
arguments = listOf(navArgument("src") { nullable = true; defaultValue = null }),
) { entry ->
val vm: InstallContentViewModel =
viewModel(factory = container.installContentViewModelFactory())
InstallContentScreen(vm = vm, onBack = entry.backOnce(nav))
InstallContentScreen(
vm = vm,
onBack = entry.backOnce(nav),
sourcePath = entry.arguments?.getString("src")?.let { Uri.decode(it) },
)
}
}
}
Loading
Loading