From 25d899487e56bc2a21873b0f40f29f005d41cce4 Mon Sep 17 00:00:00 2001 From: rfandango Date: Mon, 10 Aug 2026 22:40:42 +0100 Subject: [PATCH 01/39] [Kernel] Fire timers set with a past/epoch absolute due time --- .../src/main/cpp/xenia/src/xenia/kernel/xtimer.cc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xtimer.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xtimer.cc index 3bda326494..b713283e74 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xtimer.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xtimer.cc @@ -55,6 +55,15 @@ X_STATUS XTimer::SetTimer(int64_t due_time, uint32_t period_ms, XSystemClock::from_file_time(due_time)); } + // Guests pass an absolute due time of 0 (the 1601 NT epoch) to mean "fire + // immediately". Casting that to the host's steady_clock overflows int64 and + // yields a far-future deadline the timer never reaches, so clamp a past-due + // time here, where the comparison cannot overflow. + auto now_wsc = WinSystemClock::now(); + if (due_tp < now_wsc) { + due_tp = now_wsc; + } + // Stash routine for callback. callback_thread_ = XThread::GetCurrentThread(); callback_routine_ = routine; From b10104c9543ec992aa416bba8af910e39baa8464 Mon Sep 17 00:00:00 2001 From: rfandango Date: Mon, 10 Aug 2026 20:05:28 +0100 Subject: [PATCH 02/39] [Kernel] Close the lost-wake races behind targeted cooperative signal wakes --- .../xenia/src/xenia/kernel/guest_scheduler.cc | 27 ++++++++++++++++--- .../cpp/xenia/src/xenia/kernel/xobject.cc | 7 ++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc index 473cf51b7f..0d533af3f5 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc @@ -963,7 +963,7 @@ void GuestScheduler::WakeAll() { // most one backoff interval. bool any_blocked = false; for (int i = 0; i < kMaxCpus; ++i) { - if (cpus_[i].has_blocked.load(std::memory_order_relaxed)) { + if (cpus_[i].has_blocked.load()) { any_blocked = true; break; } @@ -1005,7 +1005,7 @@ void GuestScheduler::WakeForSignal(const XObject* object, } bool any_blocked = false; for (int i = 0; i < kMaxCpus; ++i) { - if (cpus_[i].has_blocked.load(std::memory_order_relaxed)) { + if (cpus_[i].has_blocked.load()) { any_blocked = true; break; } @@ -1137,6 +1137,25 @@ void GuestScheduler::BlockCurrentThread(uint64_t deadline_ms, } { std::lock_guard lock(lock_); + // A signal between the caller's failed poll and this park bumped the epoch + // but walked the blocked list before we joined it, so nothing would re-poll + // us before the backstop. Compare under the walk's own lock and poke this + // CPU on a mismatch; returning to re-poll instead livelocks when the epoch + // churns faster than a park roundtrip. + if (gated) { + uint32_t epoch_now = 0; + bool epoch_gated = false; + if (wait_object) { + epoch_now = wait_object->cooperative_signal_epoch(); + epoch_gated = true; + } else if (self->cooperative_wait_set_count()) { + epoch_now = self->cooperative_wait_set_epoch(); + epoch_gated = true; + } + if (epoch_gated && epoch_now != wait_epoch) { + cpus_[cpu_index].repoll_now.store(true, std::memory_order_relaxed); + } + } auto& links = self->scheduler_links(); // Park self (running, in no list) on this CPU's blocked list. links.blocked = true; @@ -1167,7 +1186,9 @@ void GuestScheduler::BlockCurrentThread(uint64_t deadline_ms, if (due && due < cpu.next_timed_repoll_ms) { cpu.next_timed_repoll_ms = due; } - cpu.has_blocked.store(true, std::memory_order_relaxed); + // seq_cst pairs with the wake pre-filters' loads: epoch-read-then-store + // here vs bump-then-load there, so at least one side always sees the other. + cpu.has_blocked.store(true); } self->guest_object()->thread_state = KTHREAD_STATE_WAITING; YieldToScheduler(); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xobject.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xobject.cc index defa1fc5aa..91a4cc02e2 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xobject.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xobject.cc @@ -395,9 +395,10 @@ void XObject::WakeCooperativeWaiters() { // Wake only the CPUs that can act on this signal instead of every CPU with // any blocked thread - broadcast wakes on every semaphore release were most // of some titles' kernel time (Eternal Sonata: 69% of CPU in futex wakes - // and the resulting empty scheduling passes). - kernel_state()->guest_scheduler()->WakeForSignal(this, - CooperativeWakeTarget()); + // and the resulting empty scheduling passes). Every watcher's CPU is woken, + // not just the permit FIFO front's, which loses the wake whenever that + // waiter leaves between the signal and its dispatch. + kernel_state()->guest_scheduler()->WakeForSignal(this, nullptr); } void XObject::EnterCooperativeWait(XThread* thread) { From a9cd34d17e9c2e693fa0be408f623ecd79379d8f Mon Sep 17 00:00:00 2001 From: rfandango Date: Mon, 10 Aug 2026 21:39:55 +0100 Subject: [PATCH 03/39] [Kernel] Warn when the repoll backstop rescues waits whose wakes were lost --- .../xenia/src/xenia/kernel/guest_scheduler.cc | 64 +++++++++++++++++-- .../xenia/src/xenia/kernel/guest_scheduler.h | 13 +++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc index 0d533af3f5..9a22c0ec04 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.cc @@ -1198,9 +1198,15 @@ void GuestScheduler::BlockCurrentThread(uint64_t deadline_ms, } } -void GuestScheduler::RereadyBlocked(int cpu_index) { +void GuestScheduler::RereadyBlocked(int cpu_index, bool poked) { uint32_t wake_mask = 0; stats_.repolls.fetch_add(1, std::memory_order_relaxed); + // Missed-wake tripwire, logged after the lock is released. + uint64_t rescued = 0; + bool warn = false; + uint64_t warn_count = 0; + uint32_t warn_tid = 0; + uint32_t warn_handle = 0; { std::lock_guard lock(lock_); Cpu& cpu = cpus_[cpu_index]; @@ -1253,6 +1259,25 @@ void GuestScheduler::RereadyBlocked(int cpu_index) { continue; } } + // Only the backstop reached this waiter, on a pass no wake poked, yet + // its gate's epoch moved: the signal's own wake never re-readied it. + if (force_all && !poked && links.wait_gated) { + XObject* obj = t->cooperative_wait_object(); + bool epoch_moved = false; + if (obj) { + epoch_moved = obj->cooperative_signal_epoch() != links.wait_epoch; + } else if (links.wait_gate_count) { + epoch_moved = + t->cooperative_wait_set_epoch() != links.wait_epoch; + } + if (epoch_moved && + !(links.wait_deadline_ms && now_ms >= links.wait_deadline_ms)) { + ++rescued; + backstop_rescue_tid_ = t->thread_id(); + backstop_rescue_handle_ = + links.wait_handle_count ? links.wait_handles[0] : 0; + } + } links.blocked = false; links.queued = true; stats_.rereadied.fetch_add(1, std::memory_order_relaxed); @@ -1273,6 +1298,30 @@ void GuestScheduler::RereadyBlocked(int cpu_index) { cpu.max_blocked_prio = kept_max_prio; cpu.next_timed_repoll_ms = next_due; cpu.has_blocked.store(kept_head != nullptr, std::memory_order_relaxed); + if (rescued) { + stats_.backstop_rescues.fetch_add(rescued, std::memory_order_relaxed); + backstop_rescue_count_ += rescued; + } + if (!backstop_rescue_window_ms_) { + backstop_rescue_window_ms_ = now_ms; + } + if (now_ms - backstop_rescue_window_ms_ >= 5000) { + if (backstop_rescue_count_ >= kBackstopRescueWarnThreshold) { + warn = true; + warn_count = backstop_rescue_count_; + warn_tid = backstop_rescue_tid_; + warn_handle = backstop_rescue_handle_; + } + backstop_rescue_window_ms_ = now_ms; + backstop_rescue_count_ = 0; + } + } + if (warn) { + XELOGW( + "GuestScheduler: {} gated waits were only re-readied by the {}ms " + "backstop in the last 5s despite a signal having arrived - wakes are " + "being lost (last: tid={:08X} handle={:08X})", + warn_count, kRepollBackstopMs, warn_tid, warn_handle); } // Wake any other dispatch thread that received a ready fiber (this one runs). for (int i = 0; i < kMaxCpus; ++i) { @@ -1298,9 +1347,9 @@ void GuestScheduler::RunLoop(int cpu_index) { // busy fiber that rarely waits would starve them. The timer runs at what // the parked waiters actually need, a wake skips it entirely. uint64_t now = Clock::QueryHostUptimeMillis(); - if (cpu.repoll_now.exchange(false, std::memory_order_relaxed) || - now >= cpu.next_timed_repoll_ms) { - RereadyBlocked(cpu_index); + bool poked = cpu.repoll_now.exchange(false, std::memory_order_relaxed); + if (poked || now >= cpu.next_timed_repoll_ms) { + RereadyBlocked(cpu_index, poked); } XThread* next = DequeueReady(cpu_index); @@ -1409,6 +1458,7 @@ void GuestScheduler::ReportStatsIfDue() { }; uint64_t repolls = take(stats_.repolls); uint64_t rereadied = take(stats_.rereadied); + uint64_t backstop_rescues = take(stats_.backstop_rescues); uint64_t idle_wakes = take(stats_.idle_wakes); uint64_t switches = take(stats_.switches); uint64_t forced = take(stats_.forced_preempts); @@ -1421,10 +1471,12 @@ void GuestScheduler::ReportStatsIfDue() { return uint64_t(double(ticks) / ticks_per_us); }; XELOGI( - "GuestScheduler: repolls {}/s (rereadied {}), idle wakes {}, switches " + "GuestScheduler: repolls {}/s (rereadied {}, backstop rescues {}), idle " + "wakes {}, switches " "{}, forced preempts {} | io {} calls, queued avg {} us max {} us, ran " "avg {} us", - repolls, rereadied, idle_wakes, switches, forced, io_calls, + repolls, rereadied, backstop_rescues, idle_wakes, switches, forced, + io_calls, io_calls ? to_us(io_queue / io_calls) : 0, to_us(io_queue_max), io_calls ? to_us(io_run / io_calls) : 0); } diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.h b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.h index fe05779a4f..0530c4486a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/guest_scheduler.h @@ -168,6 +168,9 @@ class GuestScheduler { // At least this often every blocked fiber re-polls regardless of the epoch // gate, so a missed epoch bump costs a latency blip, not a hang. static constexpr uint64_t kRepollBackstopMs = 64; + // Backstop rescues per 5s window before the missed-wake tripwire warns. + // Above the benign signal/park race rate, far below a real regression. + static constexpr uint64_t kBackstopRescueWarnThreshold = 50; // Floor on the watchdog's wake period. Below this the wakeups cost more than // the preemption accuracy is worth on a mobile SoC. static constexpr uint64_t kMinWatchdogPeriodUs = 250; @@ -239,7 +242,9 @@ class GuestScheduler { // |except| is the only ready thread. Used to honor a voluntary yield. static XThread* HighestReadyExcept(const Cpu& cpu, XThread* except); void SwitchTo(XThread* next); - void RereadyBlocked(int cpu_index); + // |poked| distinguishes a wake-driven pass from a timed one, so the + // missed-wake tripwire only counts rescues the backstop alone made. + void RereadyBlocked(int cpu_index, bool poked = false); // Parks a thread whose suspend count is nonzero until Resume drops it. // Returns false if the count already reached zero, meaning run it instead. bool ParkSuspended(XThread* thread, int cpu_index); @@ -332,6 +337,7 @@ class GuestScheduler { struct Stats { std::atomic repolls{0}; // RereadyBlocked passes std::atomic rereadied{0}; // waiters actually re-readied + std::atomic backstop_rescues{0}; // wakes the backstop covered std::atomic idle_wakes{0}; // timed wakes of a parked CPU std::atomic switches{0}; // fiber dispatches std::atomic forced_preempts{0}; // IRQL defers escaped @@ -342,6 +348,11 @@ class GuestScheduler { }; Stats stats_; uint64_t stats_last_report_ms_ = 0; + // Missed-wake tripwire window, guarded by lock_. + uint64_t backstop_rescue_window_ms_ = 0; + uint64_t backstop_rescue_count_ = 0; + uint32_t backstop_rescue_tid_ = 0; + uint32_t backstop_rescue_handle_ = 0; // Calibrated in EnsureStarted, used to render the raw-tick I/O counters. double ticks_per_us_ = 0.0; void ReportStatsIfDue(); From 47253f3a8f74c71b2c5cf7a391799bf2c9d5c163 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 11:28:15 +0100 Subject: [PATCH 04/39] [Android] Keep add-on content out of the library, but not arcade titles --- .../xendroid/compose/data/GameLibraryRepository.kt | 12 ++++++++---- .../java/xendroid/compose/data/GameMetadataCache.kt | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/xendroid/compose/data/GameLibraryRepository.kt b/app/src/main/java/xendroid/compose/data/GameLibraryRepository.kt index a5e9eae757..65f2d700b2 100644 --- a/app/src/main/java/xendroid/compose/data/GameLibraryRepository.kt +++ b/app/src/main/java/xendroid/compose/data/GameLibraryRepository.kt @@ -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) } @@ -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) diff --git a/app/src/main/java/xendroid/compose/data/GameMetadataCache.kt b/app/src/main/java/xendroid/compose/data/GameMetadataCache.kt index 3780506ce8..4f333a3142 100644 --- a/app/src/main/java/xendroid/compose/data/GameMetadataCache.kt +++ b/app/src/main/java/xendroid/compose/data/GameMetadataCache.kt @@ -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. From be3055a01ccd29a2c7f1f860dd6eec70db983ead Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 11:40:17 +0100 Subject: [PATCH 05/39] [Android] Style the pause menu as a guest panel and make it controller-navigable --- .../xendroid/compose/EmulatorHostActivity.kt | 32 +++++--- .../compose/ui/panel/GuestPanelOption.kt | 16 ++++ .../compose/ui/pause/PauseMenuPanel.kt | 80 +++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt diff --git a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt index 54e10d82dc..08ac666ea1 100644 --- a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt +++ b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt @@ -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 @@ -63,6 +60,10 @@ 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.ui.pause.PAUSE_OPTION_QUIT +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 @@ -402,17 +403,17 @@ 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, + onResume = { closeMenuAndResume() }, + onQuit = { finish() }, + modifier = Modifier.fillMaxSize(), ) } } @@ -688,9 +689,20 @@ 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 -> if (i == PAUSE_OPTION_QUIT) finish() else closeMenuAndResume() }, + { closeMenuAndResume() }) + } return null } + 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, diff --git a/app/src/main/java/xendroid/compose/ui/panel/GuestPanelOption.kt b/app/src/main/java/xendroid/compose/ui/panel/GuestPanelOption.kt index 9639644d1c..9c32dc5066 100644 --- a/app/src/main/java/xendroid/compose/ui/panel/GuestPanelOption.kt +++ b/app/src/main/java/xendroid/compose/ui/panel/GuestPanelOption.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.material3.Button @@ -52,3 +54,17 @@ fun GuestPanelOptions( content = content, ) } + +/** Side-by-side variant, for a panel whose labels are ours and known short. Give each option + * Modifier.weight(1f) so they share the width evenly. */ +@Composable +fun GuestPanelOptionsRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = content, + ) +} diff --git a/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt new file mode 100644 index 0000000000..d5847090ae --- /dev/null +++ b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt @@ -0,0 +1,80 @@ +package xendroid.compose.ui.pause + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import xendroid.compose.ui.panel.GuestPanelOption +import xendroid.compose.ui.panel.GuestPanelOptionsRow + +/** Option order, mirrored by the host's PanelNav so the controller and the touch UI agree. */ +const val PAUSE_OPTION_RESUME = 0 +const val PAUSE_OPTION_QUIT = 1 +const val PAUSE_OPTION_COUNT = 2 + +/** + * The in-game menu, styled as the guest prompt panels (keyboard, disc swap, message box) so + * every in-game overlay looks and navigates alike. An in-window Surface rather than a Dialog: + * a Dialog takes window focus and trips the host's focus-loss pause. [selected] is driven by + * the host because the D-pad arrives as hat axes that never reach a composable. + */ +@Composable +fun PauseMenuPanel( + selected: Int, + onResume: () -> Unit, + onQuit: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + // Swallow taps meant for the game surface underneath. + .pointerInput(Unit) { awaitPointerEventScope { while (true) awaitPointerEvent() } }, + contentAlignment = Alignment.TopCenter, + ) { + val compact = maxHeight < 400.dp + val outerPadding = if (compact) 8.dp else 24.dp + val innerPadding = if (compact) 12.dp else 20.dp + + Surface( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .padding(outerPadding), + shape = MaterialTheme.shapes.large, + tonalElevation = 6.dp, + ) { + Column(Modifier.padding(innerPadding)) { + Text("Paused", style = MaterialTheme.typography.titleMedium) + + GuestPanelOptionsRow(Modifier.padding(top = if (compact) 8.dp else 16.dp)) { + GuestPanelOption( + label = "Resume", + selected = selected == PAUSE_OPTION_RESUME, + onClick = onResume, + modifier = Modifier.weight(1f), + ) + GuestPanelOption( + label = "Quit to library", + selected = selected == PAUSE_OPTION_QUIT, + onClick = onQuit, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} From 37ea4d7c9a192dcd09ac61dd6add33eb146d0a52 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 12:00:29 +0100 Subject: [PATCH 06/39] [Android] Skip the startup update check in debug builds --- app/src/main/java/xendroid/compose/MainActivity.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/xendroid/compose/MainActivity.kt b/app/src/main/java/xendroid/compose/MainActivity.kt index 75ec8b4770..fa0e843197 100644 --- a/app/src/main/java/xendroid/compose/MainActivity.kt +++ b/app/src/main/java/xendroid/compose/MainActivity.kt @@ -78,6 +78,10 @@ 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)) From bae7ebbcc27451261e6f23e603fe8c491cce097c Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 12:12:37 +0100 Subject: [PATCH 07/39] [Android] Center the pause menu on screen --- app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt index d5847090ae..851ec43290 100644 --- a/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt +++ b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt @@ -43,7 +43,7 @@ fun PauseMenuPanel( .background(Color.Black.copy(alpha = 0.6f)) // Swallow taps meant for the game surface underneath. .pointerInput(Unit) { awaitPointerEventScope { while (true) awaitPointerEvent() } }, - contentAlignment = Alignment.TopCenter, + contentAlignment = Alignment.Center, ) { val compact = maxHeight < 400.dp val outerPadding = if (compact) 8.dp else 24.dp From d67d207de2311fc8319dfc5fd5cb928a299aed50 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 12:26:25 +0100 Subject: [PATCH 08/39] [Android] Put the on-screen controller behind a setting seeded from controller presence --- .../xendroid/compose/EmulatorHostActivity.kt | 35 ++++++++++++++-- .../java/xendroid/compose/MainActivity.kt | 4 ++ .../xendroid/compose/core/EmulatorSession.kt | 2 + .../compose/settings/SettingDescriptions.kt | 1 + .../compose/settings/SettingsSchema.kt | 4 ++ .../compose/settings/TouchOverlayDefault.kt | 42 +++++++++++++++++++ .../compose/ui/pause/PauseMenuPanel.kt | 17 ++++++-- .../compose/settings/SettingsSchemaTest.kt | 12 +++--- .../src/main/cpp/emulator_xendroid.cpp | 15 +++++++ emulator-core/src/main/cpp/xendroid_emu.cpp | 4 ++ .../main/java/xendroid/compose/Emulator.java | 2 + 11 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/xendroid/compose/settings/TouchOverlayDefault.kt diff --git a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt index 08ac666ea1..4ee94fb3ac 100644 --- a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt +++ b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt @@ -61,7 +61,9 @@ 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 @@ -72,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, @@ -121,6 +122,7 @@ 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) + private val showTouchOverlay = mutableStateOf(true) // HID|show_touch_overlay private val menuOpenState = mutableStateOf(false) private val keyboardRequestState = mutableStateOf(null) @@ -258,7 +260,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 by showTouchOverlay + val overlayActive = booted && cfg.globals.enabled && padVisible // onStart/onStop co-own the sampler thread: no PixelCopy polling while backgrounded. DisposableEffect(overlayActive) { overlayWantsBrightness = overlayActive @@ -275,7 +278,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, @@ -296,6 +299,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { if (!booted) return@LaunchedEffect while (isActive) { showFpsOverlay.value = session.showDebugOverlayEnabled() + showTouchOverlay.value = session.showTouchOverlayEnabled() delay(1000) } } @@ -411,6 +415,8 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { if (menuOpen) xendroidTheme { PauseMenuPanel( selected = panelSelectedState.intValue, + touchOverlayShown = showTouchOverlay.value, + onToggleTouchOverlay = { toggleTouchOverlay() }, onResume = { closeMenuAndResume() }, onQuit = { finish() }, modifier = Modifier.fillMaxSize(), @@ -692,12 +698,33 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { // Last: a guest prompt opened over the menu owns the input until it is answered. if (menuOpenState.value) { return PanelNav(PAUSE_OPTION_COUNT, - { i -> if (i == PAUSE_OPTION_QUIT) finish() else closeMenuAndResume() }, + { 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 + 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() diff --git a/app/src/main/java/xendroid/compose/MainActivity.kt b/app/src/main/java/xendroid/compose/MainActivity.kt index fa0e843197..9827b92d17 100644 --- a/app/src/main/java/xendroid/compose/MainActivity.kt +++ b/app/src/main/java/xendroid/compose/MainActivity.kt @@ -19,6 +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.settings.ConfigStore +import xendroid.compose.settings.seedTouchOverlayDefault import xendroid.compose.updater.CooldownDialog import xendroid.compose.updater.getRemainingCooldown import xendroid.compose.updater.LatestVersionDialog @@ -64,6 +66,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)) } } } diff --git a/app/src/main/java/xendroid/compose/core/EmulatorSession.kt b/app/src/main/java/xendroid/compose/core/EmulatorSession.kt index f14ce95ed8..ea619a3615 100644 --- a/app/src/main/java/xendroid/compose/core/EmulatorSession.kt +++ b/app/src/main/java/xendroid/compose/core/EmulatorSession.kt @@ -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. */ diff --git a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt index 35945d7640..bab76f9961 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt @@ -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)", diff --git a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt index 8e64367e36..c8975c59b1 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt @@ -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"), diff --git a/app/src/main/java/xendroid/compose/settings/TouchOverlayDefault.kt b/app/src/main/java/xendroid/compose/settings/TouchOverlayDefault.kt new file mode 100644 index 0000000000..89a2458b20 --- /dev/null +++ b/app/src/main/java/xendroid/compose/settings/TouchOverlayDefault.kt @@ -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" diff --git a/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt index 851ec43290..1222afb785 100644 --- a/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt +++ b/app/src/main/java/xendroid/compose/ui/pause/PauseMenuPanel.kt @@ -20,9 +20,10 @@ import xendroid.compose.ui.panel.GuestPanelOption import xendroid.compose.ui.panel.GuestPanelOptionsRow /** Option order, mirrored by the host's PanelNav so the controller and the touch UI agree. */ -const val PAUSE_OPTION_RESUME = 0 -const val PAUSE_OPTION_QUIT = 1 -const val PAUSE_OPTION_COUNT = 2 +const val PAUSE_OPTION_TOUCH_OVERLAY = 0 +const val PAUSE_OPTION_RESUME = 1 +const val PAUSE_OPTION_QUIT = 2 +const val PAUSE_OPTION_COUNT = 3 /** * The in-game menu, styled as the guest prompt panels (keyboard, disc swap, message box) so @@ -33,6 +34,8 @@ const val PAUSE_OPTION_COUNT = 2 @Composable fun PauseMenuPanel( selected: Int, + touchOverlayShown: Boolean, + onToggleTouchOverlay: () -> Unit, onResume: () -> Unit, onQuit: () -> Unit, modifier: Modifier = Modifier, @@ -60,7 +63,13 @@ fun PauseMenuPanel( Column(Modifier.padding(innerPadding)) { Text("Paused", style = MaterialTheme.typography.titleMedium) - GuestPanelOptionsRow(Modifier.padding(top = if (compact) 8.dp else 16.dp)) { + GuestPanelOption( + label = if (touchOverlayShown) "Hide controls" else "Show controls", + selected = selected == PAUSE_OPTION_TOUCH_OVERLAY, + onClick = onToggleTouchOverlay, + modifier = Modifier.padding(top = if (compact) 8.dp else 16.dp), + ) + GuestPanelOptionsRow(Modifier.padding(top = 8.dp)) { GuestPanelOption( label = "Resume", selected = selected == PAUSE_OPTION_RESUME, diff --git a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt index 08a576ba81..590c19b9b5 100644 --- a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt +++ b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt @@ -13,19 +13,19 @@ class SettingsSchemaTest { private val all = SettingsSchema.allSettings - // 98 Bool + 12 IntRange + 21 ListChoice + 2 Action = 133. Display|host_present_from_non_ui_thread + // 99 Bool + 12 IntRange + 21 ListChoice + 2 Action = 134. Display|host_present_from_non_ui_thread // is intentionally absent (forced true natively; not a valid user choice). - @Test fun total_entry_count_is_133() { - assertEquals(133, all.size) + @Test fun total_entry_count_is_134() { + assertEquals(134, all.size) assertEquals( - 133, + 134, all.count { it is Setting.Bool } + all.count { it is Setting.IntRange } + all.count { it is Setting.ListChoice } + all.count { it is Setting.Action }, ) } @Test fun counts_by_type_match_verified_inventory() { - assertEquals(98, all.count { it is Setting.Bool }) + assertEquals(99, all.count { it is Setting.Bool }) assertEquals(12, all.count { it is Setting.IntRange }) assertEquals(21, all.count { it is Setting.ListChoice }) assertEquals(2, all.count { it is Setting.Action }) @@ -51,7 +51,7 @@ class SettingsSchemaTest { @Test fun categories_present_in_legacy_order() { val expected = listOf( - "Vulkan", "Video", "UI", "Storage", "Kernel", "HID", "Memory", "XConfig", + "Vulkan", "Video", "UI", "Storage", "Kernel", "Controller", "HID", "Memory", "XConfig", "Display", "GPU", "CPU", "Logging", "Content", "General", "APU", ) assertEquals(expected, SettingsSchema.categories.map { it.title }) diff --git a/emulator-core/src/main/cpp/emulator_xendroid.cpp b/emulator-core/src/main/cpp/emulator_xendroid.cpp index 48d8ef68e5..d3328c1444 100644 --- a/emulator-core/src/main/cpp/emulator_xendroid.cpp +++ b/emulator-core/src/main/cpp/emulator_xendroid.cpp @@ -1110,6 +1110,7 @@ static void j_setup_uri_info_list_file(JNIEnv* env,jobject self,jstring jpath ){ // Toggled by the "Display|show_debug_overlay" cvar (defined in presenter.cc). DECLARE_bool(show_debug_overlay); +DECLARE_bool(show_touch_overlay); // Returns the formatted overlay text, or null when the overlay is disabled so // the Java side can simply hide the view. Polled (~4 Hz) from the UI thread; @@ -1168,6 +1169,18 @@ static jboolean j_show_debug_overlay_enabled(JNIEnv* env, jobject thiz) { return cvars::show_debug_overlay ? JNI_TRUE : JNI_FALSE; } +// EFFECTIVE HID|show_touch_overlay, polled like show_debug_overlay above so a per-game +// override applied during module load is seen after boot. +static jboolean j_show_touch_overlay_enabled(JNIEnv* env, jobject thiz) { + return cvars::show_touch_overlay ? JNI_TRUE : JNI_FALSE; +} + +// Flips the live cvar so the pause-menu toggle takes effect on the next poll; the caller +// persists it to the config separately. +static void j_set_show_touch_overlay(JNIEnv* env, jobject thiz, jboolean value) { + cvars::show_touch_overlay = value == JNI_TRUE; +} + //public native int compressIsoToZar(String isoPath,String outZarPath); // Mount the ISO at isoPath (a real host path == game.launchUri), walk its // filesystem and pack it into a VERIFIED .zar at outZarPath (a real host path, @@ -1709,6 +1722,8 @@ int register_xendroid_Emulator(JNIEnv* env){ ,{"average_fps", "()D", (void *) j_average_fps} ,{"last_frame_time_ms", "()D", (void *) j_last_frame_time_ms} ,{"show_debug_overlay_enabled", "()Z", (void *) j_show_debug_overlay_enabled} + ,{"show_touch_overlay_enabled", "()Z", (void *) j_show_touch_overlay_enabled} + ,{"set_show_touch_overlay", "(Z)V", (void *) j_set_show_touch_overlay} ,{"compressIsoToZar", "(Ljava/lang/String;Ljava/lang/String;)I", (void *) j_compressIsoToZar} ,{"compressProgress", "()F", (void *) j_compressProgress} ,{"install_content", "(Ljava/lang/String;Ljava/lang/String;)I", (void *) j_install_content} diff --git a/emulator-core/src/main/cpp/xendroid_emu.cpp b/emulator-core/src/main/cpp/xendroid_emu.cpp index 1c35a26df7..89fe53bf2a 100644 --- a/emulator-core/src/main/cpp/xendroid_emu.cpp +++ b/emulator-core/src/main/cpp/xendroid_emu.cpp @@ -67,6 +67,10 @@ DEFINE_bool(android_message_box, true, "UI"); DEFINE_string(hid, "android", "Input system. Use: [android, nop]", "HID"); +DEFINE_bool(show_touch_overlay, true, + "Draw the on-screen controller. Seeded on first launch from whether " + "a physical controller was attached, and honoured as-is after that.", + "HID"); DEFINE_path( storage_root, "", diff --git a/emulator-core/src/main/java/xendroid/compose/Emulator.java b/emulator-core/src/main/java/xendroid/compose/Emulator.java index 07f0724b10..86225be49b 100644 --- a/emulator-core/src/main/java/xendroid/compose/Emulator.java +++ b/emulator-core/src/main/java/xendroid/compose/Emulator.java @@ -43,6 +43,8 @@ public static void load_library(){ // overlay applied by LoadGameConfig at boot). Poll post-boot: the per-game override // lands on the detached boot thread, so this only reflects it after the game loads. public native boolean show_debug_overlay_enabled(); + public native boolean show_touch_overlay_enabled(); + public native void set_show_touch_overlay(boolean value); // Mount the ISO at isoPath (a REAL host ISO path == game.launchUri in // real-path mode), walk its filesystem and pack it into a VERIFIED .zar at From 15589077c617bfd710473a105991d780549b9ac6 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 16:55:27 +0100 Subject: [PATCH 09/39] [Vulkan] Give Turnip the UBWC flag hint on One UI and HyperOS --- .../src/xenia/ui/vulkan/vulkan_instance.cc | 68 +++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc b/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc index 0c7a1c9533..02be211e91 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc @@ -42,10 +42,44 @@ vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks* pAllocator); #endif #if XE_PLATFORM_xendroid||XE_PLATFORM_ANDROID +#include + #include "third_party/libadrenotools/include/adrenotools/priv.h" #include "third_party/libadrenotools/include/adrenotools/driver.h" extern std::string g_native_lib_dir; +// Turnip quirks are keyed off the GPU generation and the vendor OS, both of which have +// to be known before the driver library loads. +namespace { + +// Adreno generation (6 for a6xx), 0 when the KGSL model node is unreadable. +int AdrenoGeneration() { + char gpu_model[64] = {}; + if (FILE* f = fopen("/sys/class/kgsl/kgsl-3d0/gpu_model", "r")) { + if (!fgets(gpu_model, sizeof(gpu_model), f)) { + gpu_model[0] = '\0'; + } + fclose(f); + } + const char* model_digits = strpbrk(gpu_model, "0123456789"); + return model_digits ? atoi(model_digits) / 100 : 0; +} + +bool SystemPropertySet(const char* name) { + char value[PROP_VALUE_MAX] = {}; + return __system_property_get(name, value) > 0 && value[0] != '\0'; +} + +// Keyed on each skin's own version property, not the manufacturer: a Samsung or Xiaomi +// device running stock or custom Android does not need the hint. +bool IsVendorOsNeedingUbwcFlagHint() { + return SystemPropertySet("ro.build.version.oneui") || + SystemPropertySet("ro.mi.os.version.name") || + SystemPropertySet("ro.miui.ui.version.name"); +} + +} // namespace + DEFINE_string(vulkan_lib_path, "", "Custom Driver Library Path", "Vulkan"); DEFINE_bool( adrenotools_force_max_clocks, false, @@ -62,6 +96,13 @@ DEFINE_string( "Vulkan"); UPDATE_from_string(turnip_debug, 2026, 7, 24, 12, ""); +DEFINE_string( + fd_dev_features, "", + "FD_DEV_FEATURES overrides for the Turnip driver. Empty auto-detects the " + "vendor OSes whose framebuffer stack needs enable_tp_ubwc_flag_hint=1 " + "(Samsung One UI, Xiaomi HyperOS/MIUI); set 'none' to inject nothing.", + "Vulkan"); + DEFINE_string( ir3_debug, "", "Comma-separated IR3_DEBUG flags for the Turnip shader compiler, e.g. " @@ -127,24 +168,25 @@ std::unique_ptr VulkanInstance::Create( // which avoids a class of Adreno GPU hangs (device-loss). std::string tu_debug = cvars::turnip_debug; // Adreno 6xx: Turnip's shared-consts push delivery is broken there. - if (tu_debug.find("push_consts_per_stage") == std::string::npos) { - char gpu_model[64] = {}; - if (FILE* f = fopen("/sys/class/kgsl/kgsl-3d0/gpu_model", "r")) { - if (!fgets(gpu_model, sizeof(gpu_model), f)) { - gpu_model[0] = '\0'; - } - fclose(f); - } - const char* model_digits = strpbrk(gpu_model, "0123456789"); - if (model_digits && atoi(model_digits) / 100 == 6) { - tu_debug += tu_debug.empty() ? "push_consts_per_stage" - : ",push_consts_per_stage"; - } + if (tu_debug.find("push_consts_per_stage") == std::string::npos && + AdrenoGeneration() == 6) { + tu_debug += tu_debug.empty() ? "push_consts_per_stage" + : ",push_consts_per_stage"; } if (!tu_debug.empty()) { setenv("TU_DEBUG", tu_debug.c_str(), 1); XELOGI("Set TU_DEBUG={} for the Turnip Vulkan driver", tu_debug); } + // One UI and HyperOS/MIUI: their framebuffer stack needs the UBWC flag hint for + // Turnip to sample rendered images correctly. + std::string fd_features = cvars::fd_dev_features; + if (fd_features.empty() && IsVendorOsNeedingUbwcFlagHint()) { + fd_features = "enable_tp_ubwc_flag_hint=1"; + } + if (!fd_features.empty() && fd_features != "none") { + setenv("FD_DEV_FEATURES", fd_features.c_str(), 1); + XELOGI("Set FD_DEV_FEATURES={} for the Turnip Vulkan driver", fd_features); + } // Read when the compiler is created, so set it before the driver loads. if (!std::string(cvars::ir3_debug).empty()) { setenv("IR3_DEBUG", cvars::ir3_debug.c_str(), 1); From 394a61207dfee6af197b196fd1c949902adf709d Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 17:37:55 +0100 Subject: [PATCH 10/39] [Android] Drop input events that arrive before the input driver exists --- emulator-core/src/main/cpp/xendroid_emu.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/emulator-core/src/main/cpp/xendroid_emu.cpp b/emulator-core/src/main/cpp/xendroid_emu.cpp index 89fe53bf2a..efba523de1 100644 --- a/emulator-core/src/main/cpp/xendroid_emu.cpp +++ b/emulator-core/src/main/cpp/xendroid_emu.cpp @@ -806,10 +806,17 @@ namespace ae{ void key_event(int key_code,bool pressed,int value){ static const bool is_android=cvars::hid=="android"; - if(is_android){ - xe::hid::android::AndroidInputDriver* driver=reinterpret_cast(g_windowed_app_ref->emu->input_system()->driver(0)); - driver->OnKey(key_code,pressed,value); - } + if(!is_android) return; + // Every hop can still be null while the detached boot thread builds the emulator, + // and input arrives in that window: a controller press on the boot splash, or the + // touch overlay releasing its keys as it leaves composition. + if(!g_windowed_app_ref || !g_windowed_app_ref->emu) return; + xe::hid::InputSystem* input_system=g_windowed_app_ref->emu->input_system(); + // driver(0) indexes the vector, so the count must be checked, not the pointer. + if(!input_system || input_system->driver_count()==0) return; + auto* driver=reinterpret_cast(input_system->driver(0)); + if(!driver) return; + driver->OnKey(key_code,pressed,value); } bool is_running(){ if(!g_windowed_app_ref || !g_windowed_app_ref->emu) return false; From 3b931db8d60e23cbbced0b49a73d246e00ff7627 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 17:37:55 +0100 Subject: [PATCH 11/39] [Android] Wait for the touch-overlay setting before mounting the overlay --- .../java/xendroid/compose/EmulatorHostActivity.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt index 4ee94fb3ac..43b728e37d 100644 --- a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt +++ b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt @@ -122,7 +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) - private val showTouchOverlay = mutableStateOf(true) // HID|show_touch_overlay + // 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(null) private val menuOpenState = mutableStateOf(false) private val keyboardRequestState = mutableStateOf(null) @@ -260,7 +263,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { val alpha by animateFloatAsState( if (visible) cfg.globals.opacity else 0f, tween(500), label = "padAlpha") - val padVisible by showTouchOverlay + 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) { @@ -415,7 +418,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { if (menuOpen) xendroidTheme { PauseMenuPanel( selected = panelSelectedState.intValue, - touchOverlayShown = showTouchOverlay.value, + touchOverlayShown = showTouchOverlay.value == true, onToggleTouchOverlay = { toggleTouchOverlay() }, onResume = { closeMenuAndResume() }, onQuit = { finish() }, @@ -713,7 +716,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { /** 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 + val next = showTouchOverlay.value != true showTouchOverlay.value = next session.setShowTouchOverlay(next) lifecycleScope.launch(Dispatchers.IO) { From db6bb0f6eb7adb7696b0f06d7a5363cb46afd432 Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 18:03:02 +0100 Subject: [PATCH 12/39] [Kernel] Log the dirty-disc error and exit without running static destructors --- .../cpp/xenia/src/xenia/kernel/xam/xam_ui.cc | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_ui.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_ui.cc index 7b1798aced..a378fcd9d3 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_ui.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_ui.cc @@ -8,6 +8,8 @@ */ #include "xenia/kernel/xam/xam_ui.h" + +#include #include "xenia/app/emulator_window.h" #include "xenia/base/png_utils.h" #include "xenia/base/system.h" @@ -787,9 +789,16 @@ dword_result_t XamShowDeviceSelectorUI_entry( DECLARE_XAM_EXPORT1(XamShowDeviceSelectorUI, kUI, kImplemented); void XamShowDirtyDiscErrorUI_entry(dword_t user_index) { + // The title has decided the disc is unreadable; on Android (headless) this used to be + // a silent exit(1), which runs static destructors on a guest thread while every + // emulator thread is live and aborts inside them, leaving no log line at all. + XELOGE( + "XamShowDirtyDiscErrorUI: the title reported a dirty disc and is " + "terminating. Usually an unimplemented disc IO call (sector info, volume " + "device type, dismount) rather than a real media error."); if (cvars::headless) { - assert_always(); - exit(1); + // quick_exit skips the static destructors that turned this into an abort. + std::quick_exit(1); return; } @@ -805,8 +814,7 @@ void XamShowDirtyDiscErrorUI_entry(dword_t user_index) { new MessageBoxDialog(imgui_drawer, input_system, title, desc, {"OK"}, 0), [](MessageBoxDialog*) -> X_RESULT { return X_ERROR_SUCCESS; }, 0); // This is death, and should never return. - // TODO(benvanik): cleaner exit. - exit(1); + std::quick_exit(1); } DECLARE_XAM_EXPORT1(XamShowDirtyDiscErrorUI, kUI, kImplemented); From 1d14566792e03bbaf57301b2e689ee19ea42d40d Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 18:27:59 +0100 Subject: [PATCH 13/39] [Vulkan] Log the vendor OS probe behind the Turnip UBWC flag hint --- .../src/xenia/ui/vulkan/vulkan_instance.cc | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc b/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc index 02be211e91..2265abbb4b 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/ui/vulkan/vulkan_instance.cc @@ -65,17 +65,40 @@ int AdrenoGeneration() { return model_digits ? atoi(model_digits) / 100 : 0; } -bool SystemPropertySet(const char* name) { +std::string SystemProperty(const char* name) { char value[PROP_VALUE_MAX] = {}; - return __system_property_get(name, value) > 0 && value[0] != '\0'; + int length = __system_property_get(name, value); + return length > 0 ? std::string(value, size_t(length)) : std::string(); } -// Keyed on each skin's own version property, not the manufacturer: a Samsung or Xiaomi -// device running stock or custom Android does not need the hint. +// The version property each skin sets. Keyed on these rather than the manufacturer, so a +// Samsung or Xiaomi device running stock or custom Android is not matched. +constexpr const char* kVendorOsProperties[] = { + "ro.build.version.oneui", // Samsung One UI + "ro.mi.os.version.name", // Xiaomi HyperOS + "ro.mi.os.version.code", + "ro.miui.ui.version.name", // Xiaomi MIUI + "ro.miui.ui.version.code", +}; + +// Logs every property it reads, including the empty ones, so a device that needs the hint +// but is not matched here can be identified from its own log rather than a repro session. bool IsVendorOsNeedingUbwcFlagHint() { - return SystemPropertySet("ro.build.version.oneui") || - SystemPropertySet("ro.mi.os.version.name") || - SystemPropertySet("ro.miui.ui.version.name"); + std::string report; + bool matched = false; + for (const char* name : kVendorOsProperties) { + std::string value = SystemProperty(name); + if (!value.empty()) { + matched = true; + } + report += fmt::format(" {}='{}'", name, value); + } + XELOGI("Vendor OS probe: {} {} ({}){} -> UBWC flag hint {}", + SystemProperty("ro.product.manufacturer"), + SystemProperty("ro.product.model"), + SystemProperty("ro.build.version.release"), report, + matched ? "needed" : "not needed"); + return matched; } } // namespace @@ -179,8 +202,11 @@ std::unique_ptr VulkanInstance::Create( } // One UI and HyperOS/MIUI: their framebuffer stack needs the UBWC flag hint for // Turnip to sample rendered images correctly. + // Probed unconditionally: the log has to show what the device reported even when the + // cvar overrides the decision. + const bool needs_ubwc_flag_hint = IsVendorOsNeedingUbwcFlagHint(); std::string fd_features = cvars::fd_dev_features; - if (fd_features.empty() && IsVendorOsNeedingUbwcFlagHint()) { + if (fd_features.empty() && needs_ubwc_flag_hint) { fd_features = "enable_tp_ubwc_flag_hint=1"; } if (!fd_features.empty() && fd_features != "none") { From a3af2b57a237f1460bf140c516243b1d9393cf3c Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 18:39:03 +0100 Subject: [PATCH 14/39] [Android] Install a mandatory-install title's content straight from its disc --- .../compose/core/GameMetadataSource.kt | 32 ++++ .../ui/content/InstallContentViewModel.kt | 54 ++++++- .../src/main/cpp/emulator_xendroid.cpp | 75 +++++++++ .../xenia/vfs/content_install_standalone.cc | 152 ++++++++++++++++++ .../xenia/vfs/content_install_standalone.h | 26 +++ .../main/java/xendroid/compose/Emulator.java | 12 ++ 6 files changed, 350 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/xendroid/compose/core/GameMetadataSource.kt b/app/src/main/java/xendroid/compose/core/GameMetadataSource.kt index d379b32048..4c2d8ab301 100644 --- a/app/src/main/java/xendroid/compose/core/GameMetadataSource.kt +++ b/app/src/main/java/xendroid/compose/core/GameMetadataSource.kt @@ -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 { + 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 diff --git a/app/src/main/java/xendroid/compose/ui/content/InstallContentViewModel.kt b/app/src/main/java/xendroid/compose/ui/content/InstallContentViewModel.kt index acfe2bfb60..233e236dcb 100644 --- a/app/src/main/java/xendroid/compose/ui/content/InstallContentViewModel.kt +++ b/app/src/main/java/xendroid/compose/ui/content/InstallContentViewModel.kt @@ -45,6 +45,7 @@ class InstallContentViewModel( is PreCheck.Overwrite -> _state.value = ContentInstallState.ConfirmOverwrite(srcPath, pre.name) is PreCheck.Copy -> runCopy(srcPath, pre.name, pre.gamesDir) + is PreCheck.Disc -> runDiscInstall(srcPath, pre.items) is PreCheck.Ok -> runInstall(srcPath, pre.name, pre.label) } } @@ -77,6 +78,8 @@ class InstallContentViewModel( private sealed interface PreCheck { data class Ok(val name: String, val label: String) : PreCheck + /** A disc image carrying installable packages (a mandatory-install title). */ + data class Disc(val items: List) : PreCheck data class Overwrite(val name: String) : PreCheck /** Full game to copy into [gamesDir] (no existing container in the way). */ data class Copy(val name: String, val gamesDir: File) : PreCheck @@ -90,7 +93,7 @@ class InstallContentViewModel( val src = File(srcPath) if (!src.isFile) return PreCheck.Reject("Couldn't open the package file.") val meta = metadata.readContentHeader(srcPath) - ?: return PreCheck.Reject("Not a recognized content package (need CON/LIVE/PIRS).") + ?: return validateDisc(srcPath) val name = meta.displayName.ifBlank { src.name } // A full game (XBLA/arcade, GoD, ...) is COPIED into the games folder as a // container so the library boots it; add-on content is installed natively below. @@ -120,6 +123,55 @@ class InstallContentViewModel( return if (dest.exists()) PreCheck.Overwrite(name) else PreCheck.Copy(name, gamesDir) } + /** A picked file that is not a package may still be a disc image whose \content\ tree + * holds the install payload; anything else is rejected as before. */ + private fun validateDisc(srcPath: String): PreCheck { + val items = metadata.listDiscContent(srcPath) + if (items.isEmpty()) { + return PreCheck.Reject("Not a recognized content package (need CON/LIVE/PIRS).") + } + storageShortfall(items.sumOf { it.size })?.let { return PreCheck.Reject(it) } + return PreCheck.Disc(items) + } + + /** Installs every package on the disc, one at a time. Each is staged out of the image + * natively, so progress is reported per package rather than across the whole set. */ + private suspend fun runDiscInstall( + discPath: String, + items: List, + ) { + val scratch = File(appContext.cacheDir, "disc-install") + var installed = 0 + for ((index, item) in items.withIndex()) { + _state.value = ContentInstallState.Busy( + "Installing ${index + 1} of ${items.size}…", 0f) + val poll = viewModelScope.launch { + while (isActive) { + val p = EmulatorRuntime.emulator?.installProgress() ?: 0f + (_state.value as? ContentInstallState.Busy) + ?.let { _state.value = it.copy(progress = p.coerceIn(0f, 1f)) } + delay(200) + } + } + val status = withContext(Dispatchers.IO) { + val emu = EmulatorRuntime.emulator ?: return@withContext -1 + emu.install_disc_content( + discPath, item.innerPath, + ContentPaths.contentRoot().absolutePath, scratch.absolutePath) + } + poll.cancel() + if (status != 0) { + _state.value = ContentInstallState.Failed( + "Installed $installed of ${items.size}. " + installReasonFor(status)) + return + } + installed++ + } + runCatching { scratch.deleteRecursively() } + _state.value = ContentInstallState.Done( + "Installed $installed package(s) from the disc. Boot the play disc to use them.") + } + private suspend fun gamesDirOrNull(): File? = prefs.gameDirPath.firstOrNull()?.takeIf { it.isNotBlank() }?.let { File(it) } diff --git a/emulator-core/src/main/cpp/emulator_xendroid.cpp b/emulator-core/src/main/cpp/emulator_xendroid.cpp index d3328c1444..e5b8668f5a 100644 --- a/emulator-core/src/main/cpp/emulator_xendroid.cpp +++ b/emulator-core/src/main/cpp/emulator_xendroid.cpp @@ -1284,6 +1284,79 @@ static jint j_install_content(JNIEnv* env, jobject self, jstring srcPath, #endif } +// public native Emulator.DiscContentItem[] list_disc_content(String discPath); +// Enumerate the installable packages a disc image carries under \content\ (the +// payload a mandatory-install title copies to the HDD). Off-main (disc walk). +static jobjectArray j_list_disc_content(JNIEnv* env, jobject self, + jstring discPath) { +#if XE_PLATFORM_xendroid + if (!discPath) return nullptr; + const char* dp = env->GetStringUTFChars(discPath, nullptr); + std::filesystem::path disc = std::filesystem::u8path(dp); + env->ReleaseStringUTFChars(discPath, dp); + + std::vector items = xe::vfs::ListDiscContent(disc); + + jclass cls = env->FindClass("xendroid/compose/Emulator$DiscContentItem"); + jmethodID ctor = env->GetMethodID(cls, "", "()V"); + jfieldID fid_inner = env->GetFieldID(cls, "innerPath", "Ljava/lang/String;"); + jfieldID fid_name = env->GetFieldID(cls, "displayName", "Ljava/lang/String;"); + jfieldID fid_title = env->GetFieldID(cls, "titleId", "I"); + jfieldID fid_type = env->GetFieldID(cls, "contentType", "I"); + jfieldID fid_size = env->GetFieldID(cls, "size", "J"); + + jobjectArray arr = env->NewObjectArray((jsize)items.size(), cls, nullptr); + for (jsize i = 0; i < (jsize)items.size(); ++i) { + jobject obj = env->NewObject(cls, ctor); + env->SetObjectField(obj, fid_inner, env->NewStringUTF(items[i].inner_path.c_str())); + env->SetObjectField(obj, fid_name, env->NewStringUTF(items[i].display_name.c_str())); + env->SetIntField(obj, fid_title, (jint)items[i].title_id); + env->SetIntField(obj, fid_type, (jint)items[i].content_type); + env->SetLongField(obj, fid_size, (jlong)items[i].size); + env->SetObjectArrayElement(arr, i, obj); + env->DeleteLocalRef(obj); + } + return arr; +#else + return nullptr; +#endif +} + +// public native int install_disc_content(String discPath, String innerPath, +// String contentRoot, String scratchDir); +// Install one package named by list_disc_content. Shares installProgress with the +// standalone installer. Returns X_STATUS (0 == success). Off the main thread. +static jint j_install_disc_content(JNIEnv* env, jobject self, jstring discPath, + jstring innerPath, jstring contentRoot, + jstring scratchDir) { +#if XE_PLATFORM_xendroid + using namespace xe; + if (!discPath || !innerPath || !contentRoot || !scratchDir) { + return (jint)X_STATUS_INVALID_PARAMETER; + } + const char* dp = env->GetStringUTFChars(discPath, nullptr); + const char* ip = env->GetStringUTFChars(innerPath, nullptr); + const char* cr = env->GetStringUTFChars(contentRoot, nullptr); + const char* sd = env->GetStringUTFChars(scratchDir, nullptr); + std::filesystem::path disc = std::filesystem::u8path(dp); + std::string inner = ip; + std::filesystem::path root = std::filesystem::u8path(cr); + std::filesystem::path scratch = std::filesystem::u8path(sd); + env->ReleaseStringUTFChars(discPath, dp); + env->ReleaseStringUTFChars(innerPath, ip); + env->ReleaseStringUTFChars(contentRoot, cr); + env->ReleaseStringUTFChars(scratchDir, sd); + + g_content_progress.current.store(0); + g_content_progress.total.store(0); + xe::X_STATUS s = xe::vfs::InstallDiscContentPackage( + disc, inner, root, scratch, g_content_progress); + return (jint)s; +#else + return (jint)X_STATUS_UNSUCCESSFUL; +#endif +} + // public native ContentInfo content_header(String srcPath); // Header-only probe of an STFS/SVOD package: title_id, content_type, // content_size, display_name, icon. Returns null for a non-CON/unreadable @@ -1727,6 +1800,8 @@ int register_xendroid_Emulator(JNIEnv* env){ ,{"compressIsoToZar", "(Ljava/lang/String;Ljava/lang/String;)I", (void *) j_compressIsoToZar} ,{"compressProgress", "()F", (void *) j_compressProgress} ,{"install_content", "(Ljava/lang/String;Ljava/lang/String;)I", (void *) j_install_content} + ,{"list_disc_content", "(Ljava/lang/String;)[Lxendroid/compose/Emulator$DiscContentItem;", (void *) j_list_disc_content} + ,{"install_disc_content", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", (void *) j_install_disc_content} ,{"installProgress", "()F", (void *) j_installProgress} ,{"content_header", "(Ljava/lang/String;)Lxendroid/compose/Emulator$ContentInfo;", (void *) j_content_header} ,{"list_content", "(Ljava/lang/String;Ljava/lang/String;I)[Lxendroid/compose/Emulator$ContentItem;", (void *) j_list_content} diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.cc b/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.cc index 7fc1d5d922..05e3ec46dd 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.cc @@ -15,6 +15,7 @@ #include "xenia/base/string.h" #include "xenia/kernel/title_id_utils.h" #include "xenia/kernel/xam/content_manager.h" +#include "xenia/vfs/devices/disc_image_device.h" #include "xenia/vfs/devices/xcontent_container_device.h" #include "xenia/vfs/virtual_file_system.h" #include "xenia/xbox.h" @@ -116,6 +117,157 @@ X_STATUS InstallContentPackageStandalone( namespace { +// Mounts a disc image read-only for the content walk. Null when the image is not +// a readable disc. +std::unique_ptr OpenDisc( + const std::filesystem::path& disc_path) { + auto device = std::make_unique("", disc_path); + if (!device->Initialize()) { + XELOGE("Disc content: not a readable disc image: {}", disc_path.string()); + return nullptr; + } + return device; +} + +bool IsDirectory(const Entry* entry) { + return (entry->attributes() & kFileAttributeDirectory) != 0; +} + +} // namespace + +std::vector ListDiscContent( + const std::filesystem::path& disc_path) { + std::vector items; + std::unique_ptr device = OpenDisc(disc_path); + if (!device) { + return items; + } + Entry* content_root = device->ResolvePath("content"); + if (!content_root || !IsDirectory(content_root)) { + return items; + } + // \content\\\\. The tree encodes the title id + // and content type, so no package header has to be parsed to enumerate; the + // installer re-derives both from the container itself, authoritatively. + for (const auto& xuid_dir : content_root->children()) { + if (!IsDirectory(xuid_dir.get())) continue; + for (const auto& title_dir : xuid_dir->children()) { + if (!IsDirectory(title_dir.get())) continue; + uint32_t title_id = 0; + if (std::sscanf(title_dir->name().c_str(), "%8x", &title_id) != 1) { + continue; + } + for (const auto& type_dir : title_dir->children()) { + if (!IsDirectory(type_dir.get())) continue; + uint32_t content_type = 0; + if (std::sscanf(type_dir->name().c_str(), "%8x", &content_type) != 1) { + continue; + } + for (const auto& package : type_dir->children()) { + if (IsDirectory(package.get())) continue; + DiscContentItem item; + item.inner_path = + fmt::format("content\\{}\\{}\\{}\\{}", xuid_dir->name(), + title_dir->name(), type_dir->name(), package->name()); + item.display_name = package->name(); + item.title_id = title_id; + item.content_type = content_type; + item.size = package->size(); + items.push_back(std::move(item)); + } + } + } + } + XELOGI("Disc content: {} package(s) on {}", items.size(), disc_path.string()); + return items; +} + +X_STATUS InstallDiscContentPackage(const std::filesystem::path& disc_path, + const std::string& inner_path, + const std::filesystem::path& content_root, + const std::filesystem::path& scratch_dir, + ContentProgress& progress) { + progress.current.store(0); + progress.total.store(0); + + std::unique_ptr device = OpenDisc(disc_path); + if (!device) { + return X_STATUS_INVALID_PARAMETER; + } + Entry* entry = device->ResolvePath(inner_path); + if (!entry || IsDirectory(entry)) { + XELOGE("Disc content: no package at {} on {}", inner_path, + disc_path.string()); + return X_STATUS_OBJECT_NAME_NOT_FOUND; + } + + // The container device reads a host file (fopen + file_size), so the package + // is staged out of the image before it can be installed. Staging is counted + // as the first half of the payload so the progress bar keeps moving through + // what is otherwise a long silent copy. + const uint64_t package_size = entry->size(); + progress.total.store(package_size * 2); + + std::error_code ec; + std::filesystem::create_directories(scratch_dir, ec); + const auto space = std::filesystem::space(scratch_dir, ec); + if (!ec && space.available < package_size * 1.1f) { + XELOGE("Disc content: not enough space to stage {} ({} bytes)", inner_path, + package_size); + return X_STATUS_DISK_FULL; + } + + const std::filesystem::path staged = scratch_dir / entry->name(); + X_STATUS status = X_STATUS_SUCCESS; + { + vfs::File* in_file = nullptr; + status = entry->Open(FileAccess::kFileReadData, &in_file); + if (status != X_STATUS_SUCCESS) { + return status; + } + FILE* out_file = xe::filesystem::OpenFile(staged, "wb"); + if (!out_file) { + in_file->Destroy(); + return X_STATUS_ACCESS_DENIED; + } + constexpr size_t kChunkSize = 4 * 1024 * 1024; + std::vector buffer(kChunkSize); + size_t offset = 0; + while (offset < package_size) { + size_t bytes_read = 0; + status = in_file->ReadSync(std::span(buffer), offset, + &bytes_read); + if (status != X_STATUS_SUCCESS || !bytes_read) { + status = status != X_STATUS_SUCCESS ? status : X_STATUS_END_OF_FILE; + break; + } + if (std::fwrite(buffer.data(), 1, bytes_read, out_file) != bytes_read) { + status = X_STATUS_DISK_FULL; + break; + } + offset += bytes_read; + progress.current.store(offset); + } + std::fclose(out_file); + in_file->Destroy(); + } + + if (status == X_STATUS_SUCCESS) { + // The staged copy is now an ordinary package; the shared installer parses + // its header and places it by content type. It resets progress, so the + // staging half is spent by this point. + status = InstallContentPackageStandalone(staged, content_root, progress); + } else { + XELOGE("Disc content: staging {} failed 0x{:08X}", inner_path, status); + } + + std::error_code cleanup_ec; + std::filesystem::remove(staged, cleanup_ec); + return status; +} + +namespace { + uint64_t DirectorySizeOnDisk(const std::filesystem::path& dir) { uint64_t total = 0; std::error_code ec; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.h b/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.h index c0dbdc0167..0beacc7a0f 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/vfs/content_install_standalone.h @@ -30,6 +30,32 @@ X_STATUS InstallContentPackageStandalone( const std::filesystem::path& src_path, const std::filesystem::path& content_root, ContentProgress& progress); +struct DiscContentItem { + std::string inner_path; // path inside the disc image, e.g. \content\...\pkg + std::string display_name; + uint32_t title_id; + uint32_t content_type; + uint64_t size; +}; + +// Enumerate the installable packages a disc image carries under \content\, the +// payload a mandatory-install title (GTA V and friends) copies to the HDD before +// it will run. Returns an empty vector for a disc with no such tree. +// Blocking disc walk -- caller MUST run off the main thread. +std::vector ListDiscContent( + const std::filesystem::path& disc_path); + +// Install one package named by ListDiscContent's inner_path. The package is +// staged out of the disc image into scratch_dir first, because the container +// device reads a host file, then installed through the standalone path above +// and the staged copy deleted. Returns X_STATUS (0 == success). +// Blocking -- caller MUST run off the main thread. +X_STATUS InstallDiscContentPackage(const std::filesystem::path& disc_path, + const std::string& inner_path, + const std::filesystem::path& content_root, + const std::filesystem::path& scratch_dir, + ContentProgress& progress); + struct InstalledContentItem { std::string pkg_dir; std::string display_name; diff --git a/emulator-core/src/main/java/xendroid/compose/Emulator.java b/emulator-core/src/main/java/xendroid/compose/Emulator.java index 86225be49b..63c5672ee2 100644 --- a/emulator-core/src/main/java/xendroid/compose/Emulator.java +++ b/emulator-core/src/main/java/xendroid/compose/Emulator.java @@ -65,6 +65,9 @@ public static void load_library(){ // under their account XUID, everything else under machine XUID 0. Returns X_STATUS // (0 == success). Blocking VFS walk -- MUST be called off the main thread. public native int install_content(String srcPath, String contentRoot); + public native DiscContentItem[] list_disc_content(String discPath); + public native int install_disc_content(String discPath, String innerPath, + String contentRoot, String scratchDir); // Fraction 0..1 of the in-flight content install (0 when none). Poll for a bar. public native float installProgress(); @@ -193,6 +196,15 @@ public static class ContentItem { public long size; } + /** An installable package found inside a disc image, from list_disc_content. */ + public static class DiscContentItem { + public String innerPath; + public String displayName; + public int titleId; + public int contentType; + public long size; + } + public static class ProfileInfo { public String xuid; // 16-char uppercase hex public String gamertag; From a6f6a29bd4a0206af1c4bae7ec47336c0f42e0fc Mon Sep 17 00:00:00 2001 From: rfandango Date: Tue, 11 Aug 2026 18:49:04 +0100 Subject: [PATCH 15/39] [Android] Offer to install a disc's content when its game is launched --- .../java/xendroid/compose/ui/AppNavHost.kt | 14 +++++- .../ui/content/InstallContentScreen.kt | 8 ++- .../compose/ui/library/GameLibraryScreen.kt | 50 ++++++++++++++++--- .../ui/library/GameLibraryViewModel.kt | 20 ++++++++ 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/xendroid/compose/ui/AppNavHost.kt b/app/src/main/java/xendroid/compose/ui/AppNavHost.kt index d3adc27bc9..299a75207e 100644 --- a/app/src/main/java/xendroid/compose/ui/AppNavHost.kt +++ b/app/src/main/java/xendroid/compose/ui/AppNavHost.kt @@ -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 -> @@ -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) }, + ) } } } diff --git a/app/src/main/java/xendroid/compose/ui/content/InstallContentScreen.kt b/app/src/main/java/xendroid/compose/ui/content/InstallContentScreen.kt index 75d0b5a000..f824cbd3d3 100644 --- a/app/src/main/java/xendroid/compose/ui/content/InstallContentScreen.kt +++ b/app/src/main/java/xendroid/compose/ui/content/InstallContentScreen.kt @@ -1,6 +1,7 @@ package xendroid.compose.ui.content import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import xendroid.compose.ui.library.FolderBrowserScreen @@ -15,11 +16,16 @@ import xendroid.compose.ui.library.FolderBrowserScreen fun InstallContentScreen( vm: InstallContentViewModel, onBack: () -> Unit, + sourcePath: String? = null, ) { val installState by vm.state.collectAsStateWithLifecycle() + // Launched for a known source (a disc the library recognised as carrying content): + // install it directly instead of asking the user to find the file again. + LaunchedEffect(sourcePath) { if (sourcePath != null) vm.install(sourcePath) } + // Only show the picker while no install is in flight; the dialogs take over after. - if (installState is ContentInstallState.Idle) { + if (sourcePath == null && installState is ContentInstallState.Idle) { FolderBrowserScreen( onFileChosen = { path -> vm.install(path) }, onCancel = onBack, diff --git a/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt b/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt index bb0f8d5baf..0b99dbd022 100644 --- a/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt +++ b/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt @@ -59,6 +59,7 @@ fun GameLibraryScreen( onOpenGamePatches: (titleId: String, gameName: String) -> Unit, onOpenContentManager: (titleId: String, gameName: String) -> Unit, onOpenInstallContent: () -> Unit, + onInstallFromDisc: (String) -> Unit, compressVm: GameCompressViewModel, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -67,6 +68,8 @@ fun GameLibraryScreen( var updateResult by remember { mutableStateOf(null) } var pendingGame by remember { mutableStateOf(null) } + // A disc whose content is not installed yet; the launch waits on the answer. + var pendingDiscInstall by remember { mutableStateOf?>(null) } var compressConfirmFor by remember { mutableStateOf(null) } val compressState by compressVm.state.collectAsStateWithLifecycle() val titleIdState by viewModel.titleIdState.collectAsStateWithLifecycle() @@ -206,12 +209,15 @@ fun GameLibraryScreen( games = s.games, viewModel = viewModel, onLaunch = { game -> - runCatching { - // Reap any stale/orphaned :emu first (single-shot core). The new - // :emu links itself to this process by binding MainAliveService, - // so nothing rides on the Intent. - EmuProcessLink.killStaleEmu(context) - context.startActivity(viewModel.buildLaunchIntent(game)) + scope.launch { + // A mandatory-install disc is still bootable, so this asks + // rather than diverting the launch on its own. + val pending = viewModel.uninstalledDiscContent(game) + if (pending.isNotEmpty()) { + pendingDiscInstall = game to pending.size + } else { + launchGame(context, viewModel, game) + } } }, onLongPress = { pendingGame = it }, @@ -246,6 +252,29 @@ fun GameLibraryScreen( null -> {} } + pendingDiscInstall?.let { (game, count) -> + AlertDialog( + onDismissRequest = { pendingDiscInstall = null }, + title = { Text("Install disc") }, + text = { + Text("This disc carries $count content package(s) the game installs before " + + "it will run. Install them now, or boot the disc anyway?") + }, + confirmButton = { + TextButton(onClick = { + pendingDiscInstall = null + onInstallFromDisc(game.launchUri) + }) { Text("Install") } + }, + dismissButton = { + TextButton(onClick = { + pendingDiscInstall = null + launchGame(context, viewModel, game) + }) { Text("Boot anyway") } + }, + ) + } + pendingGame?.let { game -> val dismiss = { pendingGame = null; viewModel.clearTitleIdRequest() } val sheetState = rememberModalBottomSheetState() @@ -566,3 +595,12 @@ private fun formatBytes(b: Long): String { do { v /= 1024.0; i++ } while (v >= 1024.0 && i < u.lastIndex) return "%.1f %s".format(v, u[i]) } + +/** Reap any stale/orphaned :emu first (single-shot core). The new :emu links itself to the + * launcher by binding MainAliveService, so nothing rides on the Intent. */ +private fun launchGame(context: Context, viewModel: GameLibraryViewModel, game: Game) { + runCatching { + EmuProcessLink.killStaleEmu(context) + context.startActivity(viewModel.buildLaunchIntent(game)) + } +} diff --git a/app/src/main/java/xendroid/compose/ui/library/GameLibraryViewModel.kt b/app/src/main/java/xendroid/compose/ui/library/GameLibraryViewModel.kt index 7bc865b96a..2f8bedf03d 100644 --- a/app/src/main/java/xendroid/compose/ui/library/GameLibraryViewModel.kt +++ b/app/src/main/java/xendroid/compose/ui/library/GameLibraryViewModel.kt @@ -13,6 +13,9 @@ import androidx.core.content.getSystemService import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import xendroid.compose.core.EmulatorRuntime +import xendroid.compose.core.ContentPaths +import java.io.File +import xendroid.compose.core.GameMetadataSource import xendroid.compose.core.ProfileBootstrap import xendroid.compose.data.Game import xendroid.compose.data.GameFormat @@ -132,6 +135,23 @@ class GameLibraryViewModel( fun clearTitleIdRequest() { _titleId.value = TitleIdState.Idle } /** Build the launch Intent that the host Activity resolves. Caller startActivity()s it. */ + /** Packages [game]'s disc carries that are not installed yet, i.e. the payload a + * mandatory-install title (GTA V and friends) needs on the HDD before it will run. + * Empty for an ordinary disc, or once its content is installed. Off-main: walks the + * disc image. */ + suspend fun uninstalledDiscContent(game: Game): List = + withContext(Dispatchers.IO) { + if (game.format != GameFormat.ISO && game.format != GameFormat.ZAR) { + return@withContext emptyList() + } + EmulatorRuntime.ensureLoaded() + GameMetadataSource().listDiscContent(game.launchUri).filterNot { item -> + val titleId = item.titleId ?: return@filterNot false + File(ContentPaths.contentDir(titleId, item.contentType), item.displayName) + .exists() + } + } + fun buildLaunchIntent(game: Game): Intent = Intent(ACTION_LAUNCH_GAME).apply { setPackage(appContext.packageName) // self; host is in this app From 9a13c591002977027a03c8de6a87817a504e52b2 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 16:07:55 +0100 Subject: [PATCH 16/39] [Vulkan] Count only resolves that stored into the texture as covering it --- .../gpu/vulkan/vulkan_render_target_cache.cc | 9 ++++++-- .../xenia/gpu/vulkan/vulkan_texture_cache.cc | 22 +++++++++++++------ .../xenia/gpu/vulkan/vulkan_texture_cache.h | 14 ++++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc index 19edd843f3..a20ea144fa 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc @@ -2299,13 +2299,18 @@ bool VulkanRenderTargetCache::TryInPassResolveCopy( dest_desc.base = resolve_info.copy_dest_base_unadjusted & 0x1FFFFFFF; dest_desc.pitch_div_32 = resolve_info.copy_dest_coordinate_info.pitch_aligned_div_32; - dest_desc.x0 = resolve_info.copy_dest_x0; - dest_desc.y0 = resolve_info.copy_dest_y0; + // Where the texels actually land: a strip resolve advances its BASE while + // copy_dest_y0 stays put, so the bare y0 would record every strip over the + // same rows - they could never tile into full coverage, and a refused strip + // would hide behind a sibling's span. + dest_desc.x0 = uint32_t(int32_t(resolve_info.copy_dest_x0) + dest_delta_x); + dest_desc.y0 = uint32_t(int32_t(resolve_info.copy_dest_y0) + dest_delta_y); dest_desc.width = width; dest_desc.height = height; dest_desc.format = uint32_t(resolve_info.copy_dest_info.copy_dest_format); dest_desc.endian = uint32_t(resolve_info.copy_dest_info.copy_dest_endian); dest_desc.is_array = uint32_t(resolve_info.copy_dest_info.copy_dest_array); + dest_desc.wrote_texture = uint32_t(writes_texture); dest_desc.frame = command_processor_.GetCurrentFrame(); texture_cache.NoteResolveDestination(dest_desc); } diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc index b468c50331..3ca2ab526b 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc @@ -1527,7 +1527,7 @@ bool VulkanTextureCache::IsResolveDestEligible(const Texture& texture) const { } return ResolveDestsCoverSurface( texture_base, texture_size, d.pitch_div_32, key.GetWidth(), - key.GetHeight(), + key.GetHeight(), key.format, uint32_t(key.endianness), static_cast(texture) .resolve_dest_written_frame()); } @@ -1536,12 +1536,10 @@ bool VulkanTextureCache::IsResolveDestEligible(const Texture& texture) const { // A byte union misses the unwritten columns of sub-rectangle resolves, so // require full-width resolves to cover every row. -bool VulkanTextureCache::ResolveDestsCoverSurface(uint32_t base, - uint32_t size_bytes, - uint32_t pitch_div_32, - uint32_t width, - uint32_t height, - uint64_t frame) const { +bool VulkanTextureCache::ResolveDestsCoverSurface( + uint32_t base, uint32_t size_bytes, uint32_t pitch_div_32, uint32_t width, + uint32_t height, xenos::TextureFormat format, uint32_t endian, + uint64_t frame) const { if (!width || !height) { return false; } @@ -1554,11 +1552,21 @@ bool VulkanTextureCache::ResolveDestsCoverSurface(uint32_t base, if (!d.width || !d.height || d.frame != frame) { continue; } + // Only a resolve that stored into the image covers it. One refused for + // pitch, format or bounds still wrote shared memory, so counting it would + // serve the previous frame's texels over the rows it never touched. + if (!d.wrote_texture) { + continue; + } // Strips advance the base, so accept any resolve inside the surface. if (d.base < base || d.base >= base + size_bytes || d.pitch_div_32 != pitch_div_32) { continue; } + if (format != GetBaseFormat(xenos::TextureFormat(d.format)) || + d.endian >= 4 || endian != d.endian) { + continue; + } // Partial width leaves columns unwritten - it can never contribute. if (d.x0 != 0 || d.width < width) { continue; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h index 3ba4c246fe..cf229bd3d8 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h @@ -38,6 +38,13 @@ class VulkanTextureCache final : public TextureCache { uint32_t format; // xenos::ColorFormat uint32_t endian; // copy_dest_info.copy_dest_endian uint32_t is_array; // copy_dest_info.copy_dest_array + // Whether the resolve also stored into the promoted texture. Every resolve + // is recorded, because promotion decides from this history and cannot store + // before it has happened, but only a resolve that actually wrote the image + // may count towards covering it - one refused for pitch, format or bounds + // updates shared memory alone, and serving over it shows the previous + // frame's texels. + uint32_t wrote_texture; // Memory is reused across scenes, so coverage must only trust resolves // from the frame being served. uint64_t frame; @@ -73,10 +80,13 @@ class VulkanTextureCache final : public TextureCache { size_t resolve_dests_next_ = 0; // Whether a resolve wrote everything the texture would be uploaded from. bool IsResolveDestEligible(const Texture& texture) const; - // Whether full-width resolves have covered every row of the surface. + // Whether full-width resolves that STORED INTO THE IMAGE have covered every + // row of the surface. Format and endian are matched per span: a same-base + // resolve in another format was refused the store, so it covers nothing. bool ResolveDestsCoverSurface(uint32_t base, uint32_t size_bytes, uint32_t pitch_div_32, uint32_t width, - uint32_t height, uint64_t frame) const; + uint32_t height, xenos::TextureFormat format, + uint32_t endian, uint64_t frame) const; VulkanTexture* FindResolveDestTexture( uint32_t base, uint32_t* base_delta_out = nullptr) const; bool ShouldPromoteToResolveDest(const TextureKey& key) const; From b177a122ce1cf1efa4788c5671b6f9a6526c2e40 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 17:04:00 +0100 Subject: [PATCH 17/39] [Vulkan] Keep promoted resolve destinations in GENERAL and fence their in-pass stores --- .../xenia/gpu/vulkan/vulkan_texture_cache.cc | 96 +++++++++++-------- .../xenia/gpu/vulkan/vulkan_texture_cache.h | 19 ++++ 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc index 3ca2ab526b..4e563c67ca 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc @@ -578,6 +578,36 @@ void VulkanTextureCache::BeginSubmission(uint64_t new_submission_index) { } } +void VulkanTextureCache::TransitionTextureForGuestShader( + VulkanTexture& texture) { + texture.MarkAsUsed(); + // A promoted resolve destination parks in GENERAL, sampleable and storable, + // so the in-pass store never needs a mid-pass layout transition. + VulkanTexture::Usage new_usage = + texture.resolve_dest_storage_view() != VK_NULL_HANDLE + ? VulkanTexture::Usage::kResolveDestStorage + : VulkanTexture::Usage::kGuestShaderSampled; + VkPipelineStageFlags dst_stage_mask; + VkAccessFlags dst_access_mask; + VkImageLayout new_layout; + GetTextureUsageMasks(new_usage, dst_stage_mask, dst_access_mask, new_layout); + VulkanTexture::Usage old_usage = texture.SetUsage(new_usage); + // An unchanged usage still needs a barrier when an in-pass store happened: + // nothing else orders the fragment's imageStore before the sampled read. + bool pending_store = texture.ConsumePendingStorageWrite(); + if (old_usage != new_usage || pending_store) { + VkPipelineStageFlags src_stage_mask; + VkAccessFlags src_access_mask; + VkImageLayout old_layout; + GetTextureUsageMasks(old_usage, src_stage_mask, src_access_mask, + old_layout); + command_processor_.PushImageMemoryBarrier( + texture.image(), ui::vulkan::util::InitializeSubresourceRange(), + src_stage_mask, dst_stage_mask, src_access_mask, dst_access_mask, + old_layout, new_layout); + } +} + void VulkanTextureCache::RequestTextures(uint32_t used_texture_mask) { #if XE_GPU_FINE_GRAINED_DRAW_SCOPES SCOPE_profile_cpu_f("gpu"); @@ -586,11 +616,6 @@ void VulkanTextureCache::RequestTextures(uint32_t used_texture_mask) { TextureCache::RequestTextures(used_texture_mask); // Transition the textures into the needed usage. - VkPipelineStageFlags dst_stage_mask; - VkAccessFlags dst_access_mask; - VkImageLayout new_layout; - GetTextureUsageMasks(VulkanTexture::Usage::kGuestShaderSampled, - dst_stage_mask, dst_access_mask, new_layout); uint32_t textures_remaining = used_texture_mask; uint32_t index; while (xe::bit_scan_forward(textures_remaining, &index)) { @@ -599,44 +624,13 @@ void VulkanTextureCache::RequestTextures(uint32_t used_texture_mask) { if (!binding) { continue; } - VulkanTexture* binding_texture = - static_cast(binding->texture); - if (binding_texture != nullptr) { - // Will be referenced by the command buffer, so mark as used. - binding_texture->MarkAsUsed(); - VulkanTexture::Usage old_usage = - binding_texture->SetUsage(VulkanTexture::Usage::kGuestShaderSampled); - if (old_usage != VulkanTexture::Usage::kGuestShaderSampled) { - VkPipelineStageFlags src_stage_mask; - VkAccessFlags src_access_mask; - VkImageLayout old_layout; - GetTextureUsageMasks(old_usage, src_stage_mask, src_access_mask, - old_layout); - command_processor_.PushImageMemoryBarrier( - binding_texture->image(), - ui::vulkan::util::InitializeSubresourceRange(), src_stage_mask, - dst_stage_mask, src_access_mask, dst_access_mask, old_layout, - new_layout); - } + if (binding->texture != nullptr) { + TransitionTextureForGuestShader( + *static_cast(binding->texture)); } - VulkanTexture* binding_texture_signed = - static_cast(binding->texture_signed); - if (binding_texture_signed != nullptr) { - binding_texture_signed->MarkAsUsed(); - VulkanTexture::Usage old_usage = binding_texture_signed->SetUsage( - VulkanTexture::Usage::kGuestShaderSampled); - if (old_usage != VulkanTexture::Usage::kGuestShaderSampled) { - VkPipelineStageFlags src_stage_mask; - VkAccessFlags src_access_mask; - VkImageLayout old_layout; - GetTextureUsageMasks(old_usage, src_stage_mask, src_access_mask, - old_layout); - command_processor_.PushImageMemoryBarrier( - binding_texture_signed->image(), - ui::vulkan::util::InitializeSubresourceRange(), src_stage_mask, - dst_stage_mask, src_access_mask, dst_access_mask, old_layout, - new_layout); - } + if (binding->texture_signed != nullptr) { + TransitionTextureForGuestShader( + *static_cast(binding->texture_signed)); } } } @@ -1407,6 +1401,13 @@ VkImageView VulkanTextureCache::GetResolveDestStorageView( if (!texture) { return VK_NULL_HANDLE; } + // A layout transition is illegal inside the render pass, so the store is + // only offered once the image is parked in GENERAL - its next guest bind + // does that, and until then the refusal leaves a coverage gap that routes + // the texture through the ordinary upload. + if (texture->usage() != VulkanTexture::Usage::kResolveDestStorage) { + return VK_NULL_HANDLE; + } if (info_out) { const TextureKey& key = texture->key(); info_out->width = key.GetWidth(); @@ -1421,6 +1422,9 @@ void VulkanTextureCache::MarkResolveDestWritten(uint32_t base, uint64_t frame) { if (VulkanTexture* texture = FindResolveDestTexture(base)) { texture->SetResolveDestWrittenFrame(frame); + // The fragment's imageStore is only ordered before later sampled reads by + // the barrier the next bind emits for this flag. + texture->SetPendingStorageWrite(); } } @@ -3651,6 +3655,14 @@ void VulkanTextureCache::GetTextureUsageMasks(VulkanTexture::Usage usage, access_mask = VK_ACCESS_SHADER_READ_BIT; layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; break; + case VulkanTexture::Usage::kResolveDestStorage: + // Sampled by guest shaders and stored by in-pass resolve fragments; + // GENERAL so a mid-pass store never needs a layout transition. + stage_mask = + guest_shader_pipeline_stages_ | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + access_mask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT; + layout = VK_IMAGE_LAYOUT_GENERAL; + break; } } diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h index cf229bd3d8..9abd6787fa 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h @@ -354,11 +354,17 @@ class VulkanTextureCache final : public TextureCache { kTransferDestination, kGuestShaderSampled, kSwapSampled, + // Promoted resolve destinations park here: GENERAL layout, sampleable + // AND storable, so an in-pass resolve never needs a mid-pass layout + // transition. STORAGE usage already forfeits UBWC on Adreno, so + // sampling from GENERAL costs these images nothing extra. + kResolveDestStorage, }; private: VkImageView resolve_dest_storage_view_ = VK_NULL_HANDLE; uint64_t resolve_dest_written_frame_ = 0; + bool pending_storage_write_ = false; public: // Takes ownership of the image and its memory. @@ -384,6 +390,15 @@ class VulkanTextureCache final : public TextureCache { void SetResolveDestWrittenFrame(uint64_t frame) { resolve_dest_written_frame_ = frame; } + Usage usage() const { return usage_; } + // An in-pass store happened and no barrier has covered it yet; the next + // bind must emit one even though the usage does not change. + bool ConsumePendingStorageWrite() { + bool pending = pending_storage_write_; + pending_storage_write_ = false; + return pending; + } + void SetPendingStorageWrite() { pending_storage_write_ = true; } // Doesn't transition (the caller must insert the barrier). Usage SetUsage(Usage new_usage) { @@ -519,6 +534,10 @@ class VulkanTextureCache final : public TextureCache { void GetTextureUsageMasks(VulkanTexture::Usage usage, VkPipelineStageFlags& stage_mask, VkAccessFlags& access_mask, VkImageLayout& layout); + // Transitions a texture for guest-shader use: kResolveDestStorage (GENERAL) + // for promoted resolve destinations, kGuestShaderSampled otherwise, with a + // barrier when the usage changes or an in-pass store is pending. + void TransitionTextureForGuestShader(VulkanTexture& texture); xenos::ClampMode NormalizeClampMode(xenos::ClampMode clamp_mode) const; From 9791f63bc5814ed5248c0cc2891bbce42cb2a9d5 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:44 +0100 Subject: [PATCH 18/39] [APU] Build queue depth from spare credits and refund unanswered pumps --- .../cpp/xenia/src/xenia/apu/audio_system.cc | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc index 88fc39f455..fe5d181a0a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc @@ -256,8 +256,45 @@ void AudioSystem::WorkerThreadMain() { if (client_callback) { SCOPE_profile_cpu_i("apu", "xe::apu::AudioSystem->client_callback"); uint64_t args[] = {client_callback_arg}; + // Nothing was queued, so no consume will release this credit; leaking + // it would permanently lower the reachable queue depth. + uint32_t submitted_before = + clients_[client_index].frames_submitted.load(); processor_->Execute(worker_thread_->thread_state(), client_callback, args, xe::countof(args)); + bool submitted = + clients_[client_index].frames_submitted.load() != submitted_before; + if (!submitted) { + client_semaphores_[client_index]->Release(1, nullptr); + } + + // Deadline pacing alone submits one block per interval, matching the + // drain rate, so the sink never builds depth nor recovers after an + // underrun. Spare credits become depth, up to apu_max_queued_frames. + // Bounded: once Execute outruns the interval, returning credits would + // trap the worker here. + uint32_t topup_budget = queued_frames_; + while (submitted && topup_budget-- && worker_running_ && !paused_ && + xe::threading::Wait(client_semaphores_[client_index].get(), false, + std::chrono::milliseconds(0)) == + xe::threading::WaitResult::kSuccess) { + bool still_in_use; + { + auto global_lock = global_critical_region_.Acquire(); + still_in_use = clients_[client_index].in_use; + } + if (!still_in_use) { + break; + } + submitted_before = clients_[client_index].frames_submitted.load(); + processor_->Execute(worker_thread_->thread_state(), client_callback, + args, xe::countof(args)); + if (clients_[client_index].frames_submitted.load() == + submitted_before) { + client_semaphores_[client_index]->Release(1, nullptr); + break; + } + } } } XELOGW("AudioWorker: loop EXITED (worker_running_={})", From e0137c9a71c9b8ccd3d5a13899cf70cb0946197d Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:44 +0100 Subject: [PATCH 19/39] [APU] Report pump durations through an ADPF hint session to hold clocks up --- .../cpp/xenia/src/xenia/apu/audio_system.cc | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc index fe5d181a0a..25a2051d52 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc @@ -17,7 +17,10 @@ #include "xenia/base/assert.h" #include "xenia/base/byte_stream.h" #if XE_PLATFORM_xendroid +#include +#include #include +#include #endif #include @@ -53,6 +56,11 @@ DEFINE_uint32(apu_max_queued_frames, 8, "delay. Lowering this value might cause performance issues. " "Value range: [4-64]", "APU"); +DEFINE_bool( + apu_performance_hint, true, + "Register an ADPF performance hint session over the guest dispatch and " + "audio threads so the power HAL holds clocks for the audio deadline.", + "APU"); UPDATE_from_uint32(apu_max_queued_frames, 2024, 8, 31, 20, 64); namespace xe { @@ -109,6 +117,91 @@ X_STATUS AudioSystem::Setup(kernel::KernelState* kernel_state) { } #if XE_PLATFORM_xendroid +// Reporting pump duration against the block deadline is the unprivileged way +// to stop a power profile parking cores below what the mixer needs. +// dlsym'd: ADPF is API 33+, minSdk is 29. +class AudioPerformanceHint { + public: + void ReportPump(uint64_t actual_us) { + if (state_ == State::kDisabled) { + return; + } + if (state_ == State::kUninitialized) { + Initialize(); + if (state_ == State::kDisabled) { + return; + } + } + report_(session_, static_cast(actual_us) * 1000); + } + + private: + enum class State { kUninitialized, kActive, kDisabled }; + + void Initialize() { + state_ = State::kDisabled; + if (!cvars::apu_performance_hint) { + return; + } + void* lib = dlopen("libandroid.so", RTLD_NOW | RTLD_NOLOAD); + if (!lib) { + return; + } + auto get_manager = reinterpret_cast( + dlsym(lib, "APerformanceHint_getManager")); + auto create_session = reinterpret_cast( + dlsym(lib, "APerformanceHint_createSession")); + report_ = reinterpret_cast( + dlsym(lib, "APerformanceHint_reportActualWorkDuration")); + if (!get_manager || !create_session || !report_) { + return; + } + void* manager = get_manager(); + if (!manager) { + return; + } + std::vector tids; + if (DIR* dir = opendir("/proc/self/task")) { + while (dirent* entry = readdir(dir)) { + int32_t tid = atoi(entry->d_name); + if (tid <= 0) { + continue; + } + char comm_path[64]; + snprintf(comm_path, sizeof(comm_path), "/proc/self/task/%d/comm", tid); + char comm[32] = {0}; + if (FILE* f = fopen(comm_path, "r")) { + fgets(comm, sizeof(comm), f); + fclose(f); + } + if (!strncmp(comm, "Guest CPU", 9) || !strncmp(comm, "Audio Worker", 12) || + !strncmp(comm, "XMA Decoder", 11)) { + tids.push_back(tid); + } + } + closedir(dir); + } + if (tids.empty()) { + return; + } + session_ = create_session(manager, tids.data(), tids.size(), + AudioSystem::kAudioPumpInterval * int64_t(1000)); + if (!session_) { + XELOGW("AudioPerformanceHint: createSession failed ({} threads)", + tids.size()); + return; + } + XELOGI("AudioPerformanceHint: session over {} threads, target {}us", + tids.size(), AudioSystem::kAudioPumpInterval); + state_ = State::kActive; + } + + State state_ = State::kUninitialized; + void* session_ = nullptr; + int (*report_)(void*, int64_t) = nullptr; +}; + // Reports the first failure rather than dropping it: an app that is not // allowed to renice would otherwise look like it had succeeded. void ApplyAudioThreadPriority(const char* what) { @@ -143,6 +236,9 @@ void AudioSystem::WorkerThreadMain() { ApplyAudioThreadPriority("Audio Worker"); auto priority_last = std::chrono::steady_clock::now(); #endif +#if XE_PLATFORM_xendroid + AudioPerformanceHint audio_perf_hint; +#endif // The host mixer releases a client's semaphore on its own coarse cadence, // but Xenos audio subsystem operates at 5.333ms interval (see @@ -260,8 +356,17 @@ void AudioSystem::WorkerThreadMain() { // it would permanently lower the reachable queue depth. uint32_t submitted_before = clients_[client_index].frames_submitted.load(); +#if XE_PLATFORM_xendroid + auto exec_begin = std::chrono::steady_clock::now(); +#endif processor_->Execute(worker_thread_->thread_state(), client_callback, args, xe::countof(args)); +#if XE_PLATFORM_xendroid + audio_perf_hint.ReportPump(static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - exec_begin) + .count())); +#endif bool submitted = clients_[client_index].frames_submitted.load() != submitted_before; if (!submitted) { From 2f5522160182e248fbff1f9e57232e42b2936a72 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:44 +0100 Subject: [PATCH 20/39] [APU] Bend playback rate toward 0.9x as the audio queue drains, never gap --- .../src/main/cpp/xe_aaudio_audio_driver.cpp | 179 ++++++++++-------- .../src/main/cpp/xe_aaudio_audio_driver.h | 13 +- 2 files changed, 109 insertions(+), 83 deletions(-) diff --git a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp index 4a785a5f48..24cc83a30e 100644 --- a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp +++ b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp @@ -27,6 +27,10 @@ DEFINE_uint32( "Depth of the Android audio buffer, in device bursts. Higher rides out " "emulator slowdowns without dropping audio, at the cost of latency.", "APU"); +DEFINE_bool(apu_aaudio_dynamic_rate, true, + "Slow playback toward 0.9x as the audio queue drains, so a guest " + "that cannot keep real time bends pitch instead of popping.", + "APU"); DEFINE_bool(apu_aaudio_log_stats, false, "Log Android audio callback statistics (gaps, queue depth, " "underruns) once a second.", @@ -192,78 +196,51 @@ aaudio_data_callback_result_t AAudioAudioDriver::AudioCallback( // shared-mode fallback and on every rebuild. The conversion below uses // channel_samples_ as its source stride, so it must run at that size // whatever this callback was handed. - const int32_t out_samples = numFrames * host_frame_channels_; if (numFrames != static_cast(driver->channel_samples_)) { driver->stat_unexpected_frames_.store(numFrames, std::memory_order_relaxed); } driver->stat_callbacks_.fetch_add(1, std::memory_order_relaxed); - // Fill the whole request, however many guest blocks that takes: a callback - // size other than channel_samples_ would otherwise pad with silence or - // discard the rest of a block. + // Queue depth steers the resample rate, slewed to avoid zipper noise: a + // producer below real time bends pitch instead of gapping. + uint32_t depth_now; + { + std::unique_lock guard(driver->frames_mutex_); + depth_now = static_cast(driver->frames_queued_.size()); + } + float rate_target = 1.0f; + if (cvars::apu_aaudio_dynamic_rate && depth_now < 6) { + rate_target = std::max(0.90f, 1.0f - 0.02f * (6 - depth_now)); + } + driver->rate_ += + std::clamp(rate_target - driver->rate_, -0.003f, 0.003f); + driver->stat_rate_milli_.store( + static_cast(driver->rate_ * 1000.0f + 0.5f), + std::memory_order_relaxed); + int32_t frames_done = 0; uint32_t releases = 0; bool gapped = false; while (frames_done < numFrames) { - if (driver->last_block_pos_ >= driver->channel_samples_) { - float* buffer = nullptr; - uint32_t depth = 0; - { - // Held only across the queue pop. - std::unique_lock guard(driver->frames_mutex_); - depth = static_cast(driver->frames_queued_.size()); - if (!driver->frames_queued_.empty()) { - buffer = driver->frames_queued_.front(); - driver->frames_queued_.pop(); - } - } - driver->stat_queue_depth_sum_.fetch_add(depth, std::memory_order_relaxed); - if (depth > driver->stat_queue_depth_max_.load(std::memory_order_relaxed)) { - driver->stat_queue_depth_max_.store(depth, std::memory_order_relaxed); + while (driver->resample_frac_ >= 1.0f) { + driver->resample_frac_ -= 1.0f; + if (driver->last_block_pos_ >= driver->channel_samples_) { + driver->LoadNextBlock(releases, gapped); } - if (!buffer) { - // Conceal only the part still owed. - driver->stat_gaps_.fetch_add(1, std::memory_order_relaxed); - gapped = true; - // ConcealGap repeats last_block_, so it can source at most one block. - const int32_t done_samples = frames_done * host_frame_channels_; - const int32_t owed_samples = out_samples - done_samples; - driver->ConcealGap( - output_buffer + done_samples, owed_samples, - std::min(owed_samples, static_cast(driver->host_block_samples_))); - break; - } - if (driver->frame_channels_ == 6) { - conversion::sequential_6_BE_to_interleaved_2_LE( - driver->last_block_.data(), buffer, driver->channel_samples_); - } else { - // Media player: already interleaved host endian stereo. - std::memcpy(driver->last_block_.data(), buffer, - driver->host_block_samples_ * sizeof(float)); - } - driver->ApplyGainAndClamp(); - driver->ApplyFadeIn(); - driver->last_block_valid_ = true; - driver->last_block_pos_ = 0; - driver->gap_blocks_ = 0; - { - std::unique_lock guard(driver->frames_mutex_); - driver->frames_unused_.push(buffer); - } - ++releases; + driver->prev_l_ = driver->cur_l_; + driver->prev_r_ = driver->cur_r_; + driver->cur_l_ = driver->last_block_[driver->last_block_pos_ * 2 + 0]; + driver->cur_r_ = driver->last_block_[driver->last_block_pos_ * 2 + 1]; + driver->last_block_pos_++; } - const int32_t chunk = std::min( - numFrames - frames_done, - static_cast(driver->channel_samples_ - - driver->last_block_pos_)); - std::memcpy( - output_buffer + frames_done * host_frame_channels_, - driver->last_block_.data() + - driver->last_block_pos_ * host_frame_channels_, - chunk * host_frame_channels_ * sizeof(float)); - driver->last_block_pos_ += static_cast(chunk); - frames_done += chunk; + const float f = driver->resample_frac_; + output_buffer[frames_done * 2 + 0] = + driver->prev_l_ + f * (driver->cur_l_ - driver->prev_l_); + output_buffer[frames_done * 2 + 1] = + driver->prev_r_ + f * (driver->cur_r_ - driver->prev_r_); + frames_done++; + driver->resample_frac_ += driver->rate_; } // One tick per block consumed, so pacing holds when the callback size is @@ -278,36 +255,72 @@ aaudio_data_callback_result_t AAudioAudioDriver::AudioCallback( return AAUDIO_CALLBACK_RESULT_CONTINUE; } -void AAudioAudioDriver::ConcealGap(float* output, int32_t out_samples, - int32_t copy_samples) { +void AAudioAudioDriver::LoadNextBlock(uint32_t& releases, bool& gapped) { + float* buffer = nullptr; + uint32_t depth = 0; + { + std::unique_lock guard(frames_mutex_); + depth = static_cast(frames_queued_.size()); + if (!frames_queued_.empty()) { + buffer = frames_queued_.front(); + frames_queued_.pop(); + } + } + stat_queue_depth_sum_.fetch_add(depth, std::memory_order_relaxed); + if (depth > stat_queue_depth_max_.load(std::memory_order_relaxed)) { + stat_queue_depth_max_.store(depth, std::memory_order_relaxed); + } + if (!buffer) { + stat_gaps_.fetch_add(1, std::memory_order_relaxed); + gapped = true; + ConcealNextBlock(); + last_block_pos_ = 0; + return; + } + if (frame_channels_ == 6) { + conversion::sequential_6_BE_to_interleaved_2_LE(last_block_.data(), buffer, + channel_samples_); + } else { + // Media player: already interleaved host endian stereo. + std::memcpy(last_block_.data(), buffer, + host_block_samples_ * sizeof(float)); + } + ApplyGainAndClamp(); + ApplyFadeIn(); + last_block_valid_ = true; + last_block_pos_ = 0; + gap_blocks_ = 0; + conceal_gain_ = 1.0f; + { + std::unique_lock guard(frames_mutex_); + frames_unused_.push(buffer); + } + ++releases; +} + +void AAudioAudioDriver::ConcealNextBlock() { // Nothing to repeat yet: startup, or straight after a mute. if (!last_block_valid_) { - std::memset(output, 0, out_samples * sizeof(float)); + std::memset(last_block_.data(), 0, host_block_samples_ * sizeof(float)); return; } - // Repeat the last block, decaying it: held flat it would buzz at the block - // rate, decayed it fades out instead of slamming to silence. - const float start_gain = std::pow(0.6f, static_cast(gap_blocks_)); - if (start_gain < 0.002f) { - std::memset(output, 0, out_samples * sizeof(float)); + // Repeat the last block, decaying it in place: held flat it would buzz at + // the block rate, decayed it fades out instead of slamming to silence. + conceal_gain_ *= 0.6f; + if (conceal_gain_ < 0.002f) { + std::memset(last_block_.data(), 0, host_block_samples_ * sizeof(float)); last_block_valid_ = false; gap_blocks_++; fade_in_pending_ = true; return; } - const float end_gain = start_gain * 0.6f; - const int32_t frames = copy_samples / host_frame_channels_; - const float step = frames > 0 ? (end_gain - start_gain) / frames : 0.0f; - + const int32_t frames = static_cast(channel_samples_); + const float step = frames > 0 ? (0.6f - 1.0f) / frames : 0.0f; for (int32_t f = 0; f < frames; ++f) { - const float g = start_gain + step * f; - output[f * 2 + 0] = last_block_[f * 2 + 0] * g; - output[f * 2 + 1] = last_block_[f * 2 + 1] * g; - } - if (out_samples > copy_samples) { - std::memset(output + copy_samples, 0, - (out_samples - copy_samples) * sizeof(float)); + const float g = 1.0f + step * f; + last_block_[f * 2 + 0] *= g; + last_block_[f * 2 + 1] *= g; } gap_blocks_++; fade_in_pending_ = true; @@ -378,10 +391,12 @@ void AAudioAudioDriver::LogAndResetStats() { } XELOGI( - "AAudio: {} cb, {} gaps ({:.1f}%), queue avg {:.2f} max {}, xruns {}, " - "clipped {} ({:.3f}%){}", + "AAudio: {} cb, {} gaps ({:.1f}%), queue avg {:.2f} max {}, rate {:.3f}, " + "xruns {}, clipped {} ({:.3f}%){}", callbacks, gaps, 100.0 * double(gaps) / double(callbacks), - double(depth_sum) / double(callbacks), depth_max, xruns, clipped, + double(depth_sum) / double(callbacks), depth_max, + stat_rate_milli_.load(std::memory_order_relaxed) / 1000.0, xruns, + clipped, played ? 100.0 * double(clipped) / double(played) : 0.0, odd_frames ? fmt::format(", UNEXPECTED framesPerCallback {}", odd_frames) : ""); diff --git a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h index 0fbab7921b..3554e7064f 100644 --- a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h +++ b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h @@ -57,7 +57,6 @@ class AAudioAudioDriver : public AudioDriver { aaudio_result_t error); // Callback thread only. - void ConcealGap(float* output, int32_t out_samples, int32_t copy_samples); void ApplyFadeIn(); // AAudio has no stream volume control, so this is done in software. // Callback thread only. @@ -113,6 +112,17 @@ class AAudioAudioDriver : public AudioDriver { bool fade_in_pending_ = false; + // Rate control state; callback thread only. + float rate_ = 1.0f; + float resample_frac_ = 0.0f; + float prev_l_ = 0.0f, prev_r_ = 0.0f; + float cur_l_ = 0.0f, cur_r_ = 0.0f; + float conceal_gain_ = 1.0f; + + // Next source block: a queued frame if there is one, else a decayed repeat. + void LoadNextBlock(uint32_t& releases, bool& gapped); + void ConcealNextBlock(); + // Written by the realtime callback, drained by recovery_thread_: relaxed // atomics only, nothing that could block the callback. std::atomic stat_callbacks_{0}; @@ -123,6 +133,7 @@ class AAudioAudioDriver : public AudioDriver { std::atomic stat_unexpected_frames_{0}; // Samples the downmix pushed past full scale. std::atomic stat_clipped_{0}; + std::atomic stat_rate_milli_{1000}; void LogAndResetStats(); // Per-driver volume (XMP): written by other threads, read by the callback. From 0feaeede5b7b5880634ffb224038c1c5a383f03a Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:44 +0100 Subject: [PATCH 21/39] [CPU] Skip VECTOR_DENORMFLUSH when FPCR.FZ already flushes inputs --- .../cpp/xenia/src/xenia/cpu/backend/a64/a64_seq_vector.cc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_seq_vector.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_seq_vector.cc index f9e8d81dec..509cbb0125 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_seq_vector.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_seq_vector.cc @@ -864,6 +864,14 @@ struct VECTOR_DENORMFLUSH static void Emit(A64Emitter& e, const EmitArgType& i) { int s = SrcVReg(e, i.src1, 0); int d = i.dest.reg().getIdx(); + // Consumers are VMX-mode float ops, which run under FPCR.FZ and so + // flush denormals in hardware. + if (e.IsFeatureEnabled(xe::arm64::kA64FZFlushesInputs)) { + if (d != s) { + e.mov(VReg(d).b16, VReg(s).b16); + } + return; + } // Extract exponent bits; if exponent == 0 and mantissa != 0, it's denormal. // Replace with signed zero (keep sign bit). // Mask: exponent = bits 30:23 of each float. From 0fd8002456a83b43e258245054f1c36d8a092780 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:45 +0100 Subject: [PATCH 22/39] [CPU] Lower vspltw as a broadcast swizzle, not an extract-splat round trip --- .../main/cpp/xenia/src/xenia/cpu/ppc/ppc_emit_altivec.cc | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_emit_altivec.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_emit_altivec.cc index 5e19d32b3e..0bbfee3062 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_emit_altivec.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_emit_altivec.cc @@ -1598,8 +1598,11 @@ int InstrEmit_vsplth(PPCHIRBuilder& f, const InstrData& i) { int InstrEmit_vspltw_(PPCHIRBuilder& f, uint32_t vd, uint32_t vb, uint32_t uimm) { // (VD.xyzw) <- (VB.uimm) - Value* w = f.Extract(f.LoadVR(vb), uimm & 0x3, INT32_TYPE); - Value* v = f.Splat(w, VEC128_TYPE); + // A broadcast swizzle is one instruction on both backends; Extract+Splat + // costs a round trip through a general register. + const uint32_t lane = uimm & 0x3; + Value* v = f.Swizzle(f.LoadVR(vb), INT32_TYPE, + MakeSwizzleMask(lane, lane, lane, lane)); f.StoreVR(vd, v); return 0; } From 6ff74bb5f094b524ea4c6597e28a913a8c4e350e Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 21:58:45 +0100 Subject: [PATCH 23/39] [CPU] Add dump_functions_at for targeted per-function translation dumps --- .../main/cpp/xenia/src/xenia/cpu/cpu_flags.cc | 5 ++ .../main/cpp/xenia/src/xenia/cpu/cpu_flags.h | 1 + .../xenia/src/xenia/cpu/ppc/ppc_translator.cc | 73 +++++++++++++++++++ .../xenia/src/xenia/cpu/ppc/ppc_translator.h | 2 + 4 files changed, 81 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc index 33c222ea88..5342d2700c 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc @@ -17,6 +17,11 @@ DEFINE_string( "database.", "CPU"); +DEFINE_string(dump_functions_at, "", + "Comma-separated guest addresses (hex) whose PPC source, " + "optimized HIR and host machine code are written to " + "/fndump_
.txt when first translated.", + "CPU"); DEFINE_bool(disassemble_functions, false, "Disassemble functions during generation.", "CPU"); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h index e639f13533..36240408bf 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h @@ -15,6 +15,7 @@ DECLARE_string(cpu); DECLARE_string(load_module_map); +DECLARE_string(dump_functions_at); DECLARE_bool(disassemble_functions); DECLARE_bool(trace_functions); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc index bfdd07e21f..3d8da38015 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc @@ -20,7 +20,10 @@ #include "xenia/base/memory.h" #include "xenia/base/profiling.h" #include "xenia/base/reset_scope.h" +#include "xenia/base/logging.h" #include "xenia/base/string.h" + +DECLARE_path(log_file); #include "xenia/cpu/compiler/compiler_passes.h" #include "xenia/cpu/cpu_flags.h" #include "xenia/cpu/ppc/ppc_frontend.h" @@ -293,6 +296,10 @@ bool PPCTranslator::Translate(GuestFunction* function, if (cvars::disassemble_functions) { debug_info_flags |= DebugInfoFlags::kDebugInfoAllDisasm; } + const bool dump_this_function = IsDumpTarget(function->address()); + if (dump_this_function) { + debug_info_flags |= DebugInfoFlags::kDebugInfoAllDisasm; + } if (cvars::trace_functions) { debug_info_flags |= DebugInfoFlags::kDebugInfoTraceFunctions; } @@ -385,8 +392,74 @@ bool PPCTranslator::Translate(GuestFunction* function, DumpMachineCode(function); + if (dump_this_function) { + DumpTargetFunction(function); + } + return true; } + +bool PPCTranslator::IsDumpTarget(uint32_t address) { + if (cvars::dump_functions_at.empty()) { + return false; + } + const std::string& list = cvars::dump_functions_at; + for (size_t pos = 0; pos < list.size();) { + size_t end = list.find(',', pos); + if (end == std::string::npos) { + end = list.size(); + } + std::string entry = list.substr(pos, end - pos); + pos = end + 1; + size_t first = entry.find_first_not_of(" \t"); + if (first == std::string::npos) { + continue; + } + entry = entry.substr(first, entry.find_last_not_of(" \t") - first + 1); + if (entry.size() > 2 && (entry[0] == '0') && + (entry[1] == 'x' || entry[1] == 'X')) { + entry = entry.substr(2); + } + if (entry.size() > 4 && + (entry.compare(0, 4, "sub_") == 0 || entry.compare(0, 4, "SUB_") == 0)) { + entry = entry.substr(4); + } + uint32_t parsed = 0; + if (std::sscanf(entry.c_str(), "%X", &parsed) == 1 && parsed == address) { + return true; + } + } + return false; +} + +void PPCTranslator::DumpTargetFunction(GuestFunction* function) { + auto* debug_info = function->debug_info(); + if (!debug_info) { + return; + } + // Next to the log, the one directory known writable on every platform. + std::filesystem::path dir = cvars::log_file.empty() + ? xe::filesystem::GetExecutableFolder() + : cvars::log_file.parent_path(); + std::filesystem::path path = + dir / fmt::format("fndump_{:08X}.txt", function->address()); + FILE* f = xe::filesystem::OpenFile(path, "wb"); + if (!f) { + XELOGE("dump_functions_at: cannot write {}", xe::path_to_utf8(path)); + return; + } + auto section = [f](const char* title, const char* body) { + fprintf(f, "\n===== %s =====\n%s\n", title, body ? body : "(none)"); + }; + fprintf(f, "guest function %08X-%08X, host code %p (%zu bytes)\n", + function->address(), function->end_address(), + function->machine_code(), function->machine_code_length()); + section("PPC SOURCE", debug_info->source_disasm()); + section("HIR (optimized)", debug_info->hir_disasm()); + section("HOST MACHINE CODE", debug_info->machine_code_disasm()); + fclose(f); + XELOGI("dump_functions_at: wrote {}", xe::path_to_utf8(path)); +} void PPCTranslator::Reset() { builder_->ResetPools(); } void PPCTranslator::DumpSource(GuestFunction* function, StringBuffer* string_buffer) { diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.h b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.h index f5e35ec84b..1dd6d40c0a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.h @@ -37,6 +37,8 @@ class PPCTranslator { private: void DumpSource(GuestFunction* function, StringBuffer* string_buffer); + static bool IsDumpTarget(uint32_t address); + static void DumpTargetFunction(GuestFunction* function); PPCFrontend* frontend_; std::unique_ptr scanner_; From 56d6bffafc2e57a675d79bc27e41bf94bc6aec77 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 22:17:42 +0100 Subject: [PATCH 24/39] [APU] Stop the audio sinks while paused and resume them at full rate --- .../src/main/cpp/xe_aaudio_audio_driver.cpp | 6 ++++++ .../cpp/xenia/src/xenia/apu/audio_system.cc | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp index 24cc83a30e..af6c84b117 100644 --- a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp +++ b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp @@ -171,6 +171,12 @@ void AAudioAudioDriver::Pause() { void AAudioAudioDriver::Resume() { std::unique_lock stream_guard(stream_mutex_); + // Resuming at the rate the drained queue asked for would play slow; block + // position and resampler history carry over, so playback stays continuous. + rate_ = 1.0f; + conceal_gain_ = 1.0f; + gap_blocks_ = 0; + stat_rate_milli_.store(1000, std::memory_order_relaxed); if (stream_initialized_ && stream_) { AAudioStream_requestStart(stream_); } diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc index 25a2051d52..e63c1d2bf6 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc @@ -675,6 +675,17 @@ void AudioSystem::Pause() { pending_work_event_->Set(); pause_fence_.Wait(); + // Only once the worker is parked: a running sink drains a queue nothing is + // refilling, which reads as a starving producer. + { + auto global_lock = global_critical_region_.Acquire(); + for (size_t i = 0; i < kMaximumClientCount; ++i) { + if (clients_[i].in_use && clients_[i].driver) { + clients_[i].driver->Pause(); + } + } + } + xma_decoder_->Pause(); } @@ -684,6 +695,15 @@ void AudioSystem::Resume() { } paused_ = false; + { + auto global_lock = global_critical_region_.Acquire(); + for (size_t i = 0; i < kMaximumClientCount; ++i) { + if (clients_[i].in_use && clients_[i].driver) { + clients_[i].driver->Resume(); + } + } + } + resume_event_->Set(); xma_decoder_->Resume(); From e12b3ce054bafe4c53eaa45993917182352194d2 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 22:25:46 +0100 Subject: [PATCH 25/39] [Android] Refresh the bundled config template instead of writing it once --- .../src/main/java/xendroid/compose/Application.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/emulator-core/src/main/java/xendroid/compose/Application.java b/emulator-core/src/main/java/xendroid/compose/Application.java index f10e014af5..555df3134d 100644 --- a/emulator-core/src/main/java/xendroid/compose/Application.java +++ b/emulator-core/src/main/java/xendroid/compose/Application.java @@ -82,9 +82,13 @@ public void onCreate() File f=new File(get_app_data_dir(),e); f.mkdirs(); } + // Refresh, not just create: it is a cache of the bundled asset, and the + // settings UI diffs against it to decide what is modified from default. File default_config_file=get_default_config_file(); - if(!default_config_file.exists()) - Utils.save_string(default_config_file,load_default_config_str(this)); + String bundled_config=load_default_config_str(this); + if(!default_config_file.exists() + || !bundled_config.equals(Utils.load_string(default_config_file))) + Utils.save_string(default_config_file,bundled_config); if(!should_delay_load()) Emulator.load_library(); From 411565d9f8a5e79f55e1394d6a9a6b96db5deb60 Mon Sep 17 00:00:00 2001 From: rfandango Date: Wed, 12 Aug 2026 22:25:46 +0100 Subject: [PATCH 26/39] [GPU] Default clear_memory_page_state to off in the bundled config --- emulator-core/src/main/assets/config/default_config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emulator-core/src/main/assets/config/default_config.toml b/emulator-core/src/main/assets/config/default_config.toml index 824cd9c866..43e476bfcd 100644 --- a/emulator-core/src/main/assets/config/default_config.toml +++ b/emulator-core/src/main/assets/config/default_config.toml @@ -101,7 +101,7 @@ present_safe_area_y = 90 # Percentage of the image hei [GPU] -clear_memory_page_state = true # Refresh state of memory pages to enable gpu written data. (Use for 'Team Ninja' Games to fix missing character models) +clear_memory_page_state = false # Refresh state of memory pages to enable gpu written data. (Use for 'Team Ninja' Games to fix missing character models) depth_float24_convert_in_pixel_shader = false # Whether to convert the depth values to 24-bit floating-point (20e4) from the host precision (32-bit floating point) directly in the pixel shaders of guest draws when using a host depth buffer. # This prevents visual artifacts (interleaved stripes of parts of surfaces rendered and parts not rendered, having either the same width in case of the "greater or equal" depth test function, or the former being much thinner than the latter with the "equal" function) if the full host precision depth data is lost. # This issue may happen if the game reloads the depth data previously evicted from the EDRAM to the RAM back to the EDRAM, but the EDRAM region that previously contained that depth buffer was overwritten by another depth buffer, or the game loads it to a different location in the EDRAM than it was previously placed at, thus Xenia is unable to restore the depth data with the original precision, and instead falls back to converting the lower-precision values, so in subsequent rendering passes for the same geometry, the actual depth values of the surfaces don't match those stored in the depth buffer anymore. From d5ee7bd53557ca24162e391f722b2a3ff618cb76 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 10:11:46 +0100 Subject: [PATCH 27/39] [GPU] Upload the readable part of a range and keep decommitted pages as-is --- .../xenia/gpu/vulkan/vulkan_shared_memory.cc | 111 +++++++++++++----- 1 file changed, 79 insertions(+), 32 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_shared_memory.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_shared_memory.cc index fa053f6ee3..eff8099fec 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_shared_memory.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_shared_memory.cc @@ -828,36 +828,9 @@ bool VulkanSharedMemory::UploadRanges( upload_regions_.clear(); VkBuffer upload_buffer_previous = VK_NULL_HANDLE; - // for (auto upload_range : upload_page_ranges) { - for (unsigned int i = 0; i < num_upload_ranges; ++i) { - uint32_t upload_range_start = upload_page_ranges[i].first; - uint32_t upload_range_length = upload_page_ranges[i].second; - trace_writer_.WriteMemoryRead(upload_range_start << page_size_log2(), - upload_range_length << page_size_log2()); - - if (upload_range_length > 0 && !cvars::gpu_allow_invalid_upload_range) { - const uint32_t range_start_addr = upload_range_start << page_size_log2(); - const uint32_t upload_range_last_page = - upload_range_start + upload_range_length - 1; - const uint32_t range_end_addr = upload_range_last_page - << page_size_log2(); - - const memory::PageAccess start_access = - memory().GetPhysicalHeap()->QueryRangeAccess(range_start_addr, - range_start_addr); - const memory::PageAccess end_access = - memory().GetPhysicalHeap()->QueryRangeAccess(range_end_addr, - range_end_addr); - if (start_access == xe::memory::PageAccess::kNoAccess || - end_access == xe::memory::PageAccess::kNoAccess) { - XELOGE( - "Vulkan shared memory: Invalid upload range {:08X} length {:08X}", - upload_range_start, upload_range_length); - successful = false; - break; - } - } - + // Uploads one span of pages that is known host-readable. + auto upload_span = [&](uint32_t upload_range_start, + uint32_t upload_range_length) -> bool { while (upload_range_length) { VkBuffer upload_buffer; VkDeviceSize upload_buffer_offset, upload_buffer_size; @@ -867,8 +840,7 @@ bool VulkanSharedMemory::UploadRanges( upload_buffer_size); if (upload_buffer_mapping == nullptr) { XELOGE("Shared memory: Failed to get a Vulkan upload buffer"); - successful = false; - break; + return false; } MakeRangeValid(upload_range_start << page_size_log2(), uint32_t(upload_buffer_size), false); @@ -903,9 +875,84 @@ bool VulkanSharedMemory::UploadRanges( upload_range_start += upload_buffer_pages; upload_range_length -= upload_buffer_pages; } + return true; + }; + + // Marks decommitted pages valid without copying them: the buffer keeps its + // last-uploaded contents, which is what the console's GPU would have read + // out of physical RAM. + auto skip_span = [&](uint32_t page_first, uint32_t page_end) { + MakeRangeValid(page_first << page_size_log2(), + (page_end - page_first) << page_size_log2(), false); + XELOGW( + "Vulkan shared memory: skipped upload of {} decommitted page(s) at " + "{:08X}, keeping last-uploaded contents", + page_end - page_first, page_first << page_size_log2()); + }; + + // Pages the guest decommitted are PROT_NONE host-side, so they can be + // neither copied nor refused: a refusal is retried by every draw that still + // references the memory, and games legally free memory that live textures + // point at (the console GPU reads physical RAM, ignoring CPU page tables). + for (unsigned int i = 0; i < num_upload_ranges; ++i) { + const uint32_t upload_range_start = upload_page_ranges[i].first; + const uint32_t upload_range_length = upload_page_ranges[i].second; + trace_writer_.WriteMemoryRead(upload_range_start << page_size_log2(), + upload_range_length << page_size_log2()); + if (!upload_range_length) { + continue; + } + + auto* heap = memory().GetPhysicalHeap(); + if (cvars::gpu_allow_invalid_upload_range || + heap->QueryRangeAccess( + upload_range_start << page_size_log2(), + (upload_range_start + upload_range_length - 1) + << page_size_log2()) != xe::memory::PageAccess::kNoAccess) { + if (!upload_span(upload_range_start, upload_range_length)) { + successful = false; + break; + } + continue; + } + + uint32_t span_start = UINT32_MAX; + uint32_t skipped_start = UINT32_MAX; + for (uint32_t page = upload_range_start; + page <= upload_range_start + upload_range_length; ++page) { + const uint32_t page_addr = page << page_size_log2(); + const bool readable = + page < upload_range_start + upload_range_length && + heap->QueryRangeAccess(page_addr, page_addr) != + xe::memory::PageAccess::kNoAccess; + if (readable) { + if (skipped_start != UINT32_MAX) { + skip_span(skipped_start, page); + skipped_start = UINT32_MAX; + } + if (span_start == UINT32_MAX) { + span_start = page; + } + } else { + if (span_start != UINT32_MAX) { + if (!upload_span(span_start, page - span_start)) { + successful = false; + break; + } + span_start = UINT32_MAX; + } + if (page < upload_range_start + upload_range_length && + skipped_start == UINT32_MAX) { + skipped_start = page; + } + } + } if (!successful) { break; } + if (skipped_start != UINT32_MAX) { + skip_span(skipped_start, upload_range_start + upload_range_length); + } } if (!upload_regions_.empty()) { assert_true(upload_buffer_previous != VK_NULL_HANDLE); From 70163b498ee2b9a5d6ade7dfe63feb7b4c145759 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 10:53:03 +0100 Subject: [PATCH 28/39] [APU] Refill the audio buffer from its real depth, slow playback only near empty --- .../compose/settings/SettingDescriptions.kt | 1 + .../compose/settings/SettingsSchema.kt | 1 + .../compose/settings/SettingsSchemaTest.kt | 10 ++-- .../src/main/cpp/xe_aaudio_audio_driver.cpp | 16 +++++- .../src/main/cpp/xe_aaudio_audio_driver.h | 1 + .../cpp/xenia/src/xenia/apu/audio_driver.h | 3 ++ .../cpp/xenia/src/xenia/apu/audio_system.cc | 52 +++++++++---------- 7 files changed, 50 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt index bab76f9961..782e6e1173 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt @@ -20,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", diff --git a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt index c8975c59b1..fda0c60cc7 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt @@ -240,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), diff --git a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt index 590c19b9b5..a4d70834a7 100644 --- a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt +++ b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt @@ -13,19 +13,19 @@ class SettingsSchemaTest { private val all = SettingsSchema.allSettings - // 99 Bool + 12 IntRange + 21 ListChoice + 2 Action = 134. Display|host_present_from_non_ui_thread + // 100 Bool + 12 IntRange + 21 ListChoice + 2 Action = 135. Display|host_present_from_non_ui_thread // is intentionally absent (forced true natively; not a valid user choice). - @Test fun total_entry_count_is_134() { - assertEquals(134, all.size) + @Test fun total_entry_count_is_135() { + assertEquals(135, all.size) assertEquals( - 134, + 135, all.count { it is Setting.Bool } + all.count { it is Setting.IntRange } + all.count { it is Setting.ListChoice } + all.count { it is Setting.Action }, ) } @Test fun counts_by_type_match_verified_inventory() { - assertEquals(99, all.count { it is Setting.Bool }) + assertEquals(100, all.count { it is Setting.Bool }) assertEquals(12, all.count { it is Setting.IntRange }) assertEquals(21, all.count { it is Setting.ListChoice }) assertEquals(2, all.count { it is Setting.Action }) diff --git a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp index af6c84b117..486b0db076 100644 --- a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp +++ b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.cpp @@ -42,6 +42,9 @@ namespace aaudio { static constexpr uint32_t kStatsIntervalMs = 1000; +// Sink depth, in blocks, below which playback slows to buy the producer time. +static constexpr uint32_t kRateControlDepth = 3; + AAudioAudioDriver::AAudioAudioDriver(Memory* memory, xe::threading::Semaphore* semaphore, uint32_t frequency, uint32_t channels, @@ -182,6 +185,11 @@ void AAudioAudioDriver::Resume() { } } +size_t AAudioAudioDriver::GetQueuedFrameCount() { + std::unique_lock guard(frames_mutex_); + return frames_queued_.size(); +} + void AAudioAudioDriver::SetVolume(float volume) { // AAudio has no per-stream volume; applied to the samples in the callback. driver_volume_.store(std::clamp(volume, 0.0f, 1.0f), @@ -215,9 +223,13 @@ aaudio_data_callback_result_t AAudioAudioDriver::AudioCallback( std::unique_lock guard(driver->frames_mutex_); depth_now = static_cast(driver->frames_queued_.size()); } + // Only once the sink is close to running dry, and always below the depth the + // producer refills to (AudioSystem's top-up line) - bending pitch at a depth + // the producer is content with leaves the rate permanently modulated. float rate_target = 1.0f; - if (cvars::apu_aaudio_dynamic_rate && depth_now < 6) { - rate_target = std::max(0.90f, 1.0f - 0.02f * (6 - depth_now)); + if (cvars::apu_aaudio_dynamic_rate && depth_now < kRateControlDepth) { + rate_target = + std::max(0.90f, 1.0f - 0.05f * float(kRateControlDepth - depth_now)); } driver->rate_ += std::clamp(rate_target - driver->rate_, -0.003f, 0.003f); diff --git a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h index 3554e7064f..4fd2756e46 100644 --- a/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h +++ b/emulator-core/src/main/cpp/xe_aaudio_audio_driver.h @@ -41,6 +41,7 @@ class AAudioAudioDriver : public AudioDriver { void Pause() override; void Resume() override; void SetVolume(float volume) override; + size_t GetQueuedFrameCount() override; void SubmitFrame(float* frame) override; void Shutdown(); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_driver.h b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_driver.h index c7a4101ef4..0d35bdeb40 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_driver.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_driver.h @@ -34,6 +34,9 @@ class AudioDriver { virtual void Pause() = 0; virtual void Resume() = 0; virtual void SetVolume(float volume) = 0; + // Frames submitted but not yet played. Drivers that cannot report it leave + // the default, which keeps callers on their paced path. + virtual size_t GetQueuedFrameCount() { return 0; } }; } // namespace apu diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc index e63c1d2bf6..49dc199969 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/apu/audio_system.cc @@ -56,6 +56,10 @@ DEFINE_uint32(apu_max_queued_frames, 8, "delay. Lowering this value might cause performance issues. " "Value range: [4-64]", "APU"); +DEFINE_bool(apu_pump_topup, true, + "Let the audio buffer refill at up to twice real time while it is " + "running low, instead of only one frame per 5.33ms interval.", + "APU"); DEFINE_bool( apu_performance_hint, true, "Register an ADPF performance hint session over the guest dispatch and " @@ -352,8 +356,6 @@ void AudioSystem::WorkerThreadMain() { if (client_callback) { SCOPE_profile_cpu_i("apu", "xe::apu::AudioSystem->client_callback"); uint64_t args[] = {client_callback_arg}; - // Nothing was queued, so no consume will release this credit; leaking - // it would permanently lower the reachable queue depth. uint32_t submitted_before = clients_[client_index].frames_submitted.load(); #if XE_PLATFORM_xendroid @@ -369,36 +371,32 @@ void AudioSystem::WorkerThreadMain() { #endif bool submitted = clients_[client_index].frames_submitted.load() != submitted_before; - if (!submitted) { - client_semaphores_[client_index]->Release(1, nullptr); - } // Deadline pacing alone submits one block per interval, matching the - // drain rate, so the sink never builds depth nor recovers after an - // underrun. Spare credits become depth, up to apu_max_queued_frames. - // Bounded: once Execute outruns the interval, returning credits would - // trap the worker here. - uint32_t topup_budget = queued_frames_; - while (submitted && topup_budget-- && worker_running_ && !paused_ && - xe::threading::Wait(client_semaphores_[client_index].get(), false, - std::chrono::milliseconds(0)) == - xe::threading::WaitResult::kSuccess) { - bool still_in_use; - { - auto global_lock = global_critical_region_.Acquire(); - still_in_use = clients_[client_index].in_use; - } - if (!still_in_use) { - break; + // drain rate, so the sink cannot refill after an underrun. Add at most + // one extra frame per interval while it is below half full: the guest + // then runs at most twice real time, briefly. Credits alone are not a + // usable signal for this - a guest that submits several frames from one + // callback, or from another thread, breaks the one-credit-one-frame + // assumption and the buffer gets driven far past real time, which some + // titles answer with corrupted audio. + AudioDriver* driver = nullptr; + { + auto global_lock = global_critical_region_.Acquire(); + if (clients_[client_index].in_use) { + driver = clients_[client_index].driver; } - submitted_before = clients_[client_index].frames_submitted.load(); + } + // Refill to above the driver's rate-control depth, or playback slows at + // a depth this loop is happy to sit at. + const size_t topup_below = std::max(queued_frames_ / 2, 4); + if (submitted && cvars::apu_pump_topup && driver && !paused_ && + worker_running_ && driver->GetQueuedFrameCount() < topup_below && + xe::threading::Wait(client_semaphores_[client_index].get(), false, + std::chrono::milliseconds(0)) == + xe::threading::WaitResult::kSuccess) { processor_->Execute(worker_thread_->thread_state(), client_callback, args, xe::countof(args)); - if (clients_[client_index].frames_submitted.load() == - submitted_before) { - client_semaphores_[client_index]->Release(1, nullptr); - break; - } } } } From 162c86ed1833bc7dcae8d52be640541ca79d69ab Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 11:42:05 +0100 Subject: [PATCH 29/39] [Vulkan] Write sampled-image descriptors with the layout the texture lives in --- .../gpu/vulkan/vulkan_command_processor.cc | 13 +++++++++++-- .../xenia/gpu/vulkan/vulkan_texture_cache.cc | 18 ++++++++++++++++++ .../xenia/gpu/vulkan/vulkan_texture_cache.h | 6 ++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc index 5488bb7b4e..a4f63ea61a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc @@ -7919,6 +7919,11 @@ bool VulkanCommandProcessor::UpdateBindings(const VulkanShader* vertex_shader, texture_cache->GetActiveBindingOrNullImageView( texture_binding.fetch_constant, texture_binding.dimension, bool(texture_binding.is_signed))))); + // The written descriptor also carries the image layout, which changes + // when a texture is promoted to a resolve destination mid-frame. + scratch.push_back(uint64_t(texture_cache->GetActiveBindingImageLayout( + texture_binding.fetch_constant, texture_binding.dimension, + bool(texture_binding.is_signed)))); } } if (sampler_count) { @@ -8091,7 +8096,9 @@ bool VulkanCommandProcessor::UpdateBindings(const VulkanShader* vertex_shader, texture_binding.fetch_constant, texture_binding.dimension, bool(texture_binding.is_signed)); descriptor_image_info.imageLayout = - VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + texture_cache_->GetActiveBindingImageLayout( + texture_binding.fetch_constant, texture_binding.dimension, + bool(texture_binding.is_signed)); } } size_t vertex_sampler_image_info_offset = descriptor_write_image_info_.size(); @@ -8114,7 +8121,9 @@ bool VulkanCommandProcessor::UpdateBindings(const VulkanShader* vertex_shader, texture_binding.fetch_constant, texture_binding.dimension, bool(texture_binding.is_signed)); descriptor_image_info.imageLayout = - VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + texture_cache_->GetActiveBindingImageLayout( + texture_binding.fetch_constant, texture_binding.dimension, + bool(texture_binding.is_signed)); } } size_t pixel_sampler_image_info_offset = descriptor_write_image_info_.size(); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc index 4e563c67ca..21427ba498 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc @@ -635,6 +635,24 @@ void VulkanTextureCache::RequestTextures(uint32_t used_texture_mask) { } } +VkImageLayout VulkanTextureCache::GetActiveBindingImageLayout( + uint32_t fetch_constant_index, xenos::FetchOpDimension dimension, + bool is_signed) const { + const TextureBinding* binding = GetValidTextureBinding(fetch_constant_index); + if (binding && AreDimensionsCompatible(dimension, binding->key.dimension)) { + const Texture* texture = + (is_signed && texture_util::IsAnySignSigned(binding->swizzled_signs) && + IsSignedVersionSeparateForFormat(binding->key)) + ? binding->texture_signed + : binding->texture; + if (texture && static_cast(texture)->usage() == + VulkanTexture::Usage::kResolveDestStorage) { + return VK_IMAGE_LAYOUT_GENERAL; + } + } + return VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; +} + VkImageView VulkanTextureCache::GetActiveBindingOrNullImageView( uint32_t fetch_constant_index, xenos::FetchOpDimension dimension, bool is_signed) { diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h index 9abd6787fa..3f54398bc6 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.h @@ -159,6 +159,12 @@ class VulkanTextureCache final : public TextureCache { // preceding host GPU work. void RequestTextures(uint32_t used_texture_mask) override; + // Layout the active binding's image currently resides in: GENERAL for + // promoted resolve destinations, SHADER_READ_ONLY_OPTIMAL otherwise (and for + // the null views). Descriptor writes must match it or sampling is undefined. + VkImageLayout GetActiveBindingImageLayout(uint32_t fetch_constant_index, + xenos::FetchOpDimension dimension, + bool is_signed) const; VkImageView GetActiveBindingOrNullImageView(uint32_t fetch_constant_index, xenos::FetchOpDimension dimension, bool is_signed); From 8e48dd4af1792991f6d73237b4659f6a2f6eb798 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 11:42:05 +0100 Subject: [PATCH 30/39] [Vulkan] Bind the pipeline captured at draw record time, dropping unready draws --- .../xenia/gpu/vulkan/deferred_command_buffer.cc | 11 +++++------ .../src/xenia/gpu/vulkan/deferred_command_buffer.h | 11 +++++++---- .../xenia/gpu/vulkan/vulkan_command_processor.cc | 14 ++++++++++++-- .../xenia/gpu/vulkan/vulkan_command_processor.h | 3 +++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.cc index 97ce2e4935..9236dae5d9 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.cc @@ -142,15 +142,14 @@ void DeferredCommandBuffer::Execute(VkCommandBuffer command_buffer) { case Command::kVkBindPipelineDeferred: { auto& args = *reinterpret_cast(stream); - VkPipeline pipeline = args.pipeline->load(std::memory_order_acquire); - if (pipeline != VK_NULL_HANDLE) { + if (args.pipeline != VK_NULL_HANDLE) { dfn.vkCmdBindPipeline(command_buffer, args.pipeline_bind_point, - pipeline); + args.pipeline); guest_graphics_pipeline_unready = false; } else { - // Asynchronous creation hasn't completed (vulkan_async_skip_draws - // replay without the submission-boundary wait) or failed entirely - - // drop the draws recorded for this pipeline. + // The asynchronous creation had not completed when the draws were + // recorded, so their descriptor bindings cover only the interim + // pipeline layout - drop them, even if the pipeline exists by now. guest_graphics_pipeline_unready = true; } } break; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.h index c7ffde88ec..9d7dae3e4b 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/deferred_command_buffer.h @@ -122,11 +122,11 @@ class DeferredCommandBuffer { // graphics pipelines; the external / compute bind paths keep using the // by-value CmdVkBindPipeline. void CmdVkBindPipelineDeferred(VkPipelineBindPoint pipeline_bind_point, - const std::atomic* pipeline) { + VkPipeline pipeline_at_record) { auto& args = *reinterpret_cast(WriteCommand( Command::kVkBindPipelineDeferred, sizeof(ArgsVkBindPipelineDeferred))); args.pipeline_bind_point = pipeline_bind_point; - args.pipeline = pipeline; + args.pipeline = pipeline_at_record; } void CmdVkBindVertexBuffers(uint32_t first_binding, uint32_t binding_count, @@ -752,8 +752,11 @@ class DeferredCommandBuffer { struct ArgsVkBindPipelineDeferred { VkPipelineBindPoint pipeline_bind_point; - // Stable pointer to a pipeline slot; loaded (acquire) at Execute() time. - const std::atomic* pipeline; + // The slot's handle as observed at record time; the recorded draws' + // descriptor bindings only cover this pipeline's layout. Null when the + // async creation had not finished by then - a slot that fills in between + // record and Execute() must still drop its draws. + VkPipeline pipeline; }; struct ArgsVkBindVertexBuffers { diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc index a4f63ea61a..ad4507c49c 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc @@ -3685,6 +3685,7 @@ void VulkanCommandProcessor::BindExternalGraphicsPipeline( pipeline); current_external_graphics_pipeline_ = pipeline; current_guest_graphics_pipeline_ = nullptr; + current_guest_graphics_pipeline_handle_ = VK_NULL_HANDLE; current_guest_graphics_pipeline_layout_ = VK_NULL_HANDLE; } @@ -4251,10 +4252,18 @@ bool VulkanCommandProcessor::IssueDraw(xenos::PrimitiveType prim_type, // invalidation can be re-run after in-pass transfers replace the bound // pipeline (see below, after entering the render pass). auto bind_guest_graphics_pipeline = [&]() { - if (current_guest_graphics_pipeline_ != &pipeline->pipeline) { + // Bind the handle observed when this draw's bindings were decided + // (current_pipeline, loaded above), not the slot at replay time: the + // recorded descriptor sets cover only that pipeline's layout. A draw + // recorded while the slot was empty is dropped at replay even if the + // async creation finishes in between. De-dup on the handle, so the first + // draw after the slot fills (or swaps placeholder to real) re-records. + if (current_guest_graphics_pipeline_ != &pipeline->pipeline || + current_guest_graphics_pipeline_handle_ != current_pipeline) { deferred_command_buffer_.CmdVkBindPipelineDeferred( - VK_PIPELINE_BIND_POINT_GRAPHICS, &pipeline->pipeline); + VK_PIPELINE_BIND_POINT_GRAPHICS, current_pipeline); current_guest_graphics_pipeline_ = &pipeline->pipeline; + current_guest_graphics_pipeline_handle_ = current_pipeline; current_external_graphics_pipeline_ = VK_NULL_HANDLE; } auto pipeline_layout = static_cast( @@ -6212,6 +6221,7 @@ bool VulkanCommandProcessor::BeginSubmission(bool is_guest_command) { // without emitting an end (it would land in the new, unrelated buffer). pass_ts_open_pair_ = UINT32_MAX; current_guest_graphics_pipeline_ = nullptr; + current_guest_graphics_pipeline_handle_ = VK_NULL_HANDLE; current_external_graphics_pipeline_ = VK_NULL_HANDLE; current_external_compute_pipeline_ = VK_NULL_HANDLE; current_guest_graphics_pipeline_layout_ = nullptr; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h index da791ed026..7abeb819fc 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h @@ -1140,6 +1140,9 @@ class VulkanCommandProcessor final : public CommandProcessor { // bind de-dup compares slot pointers and the deferred command buffer // resolves the handle at replay. const std::atomic* current_guest_graphics_pipeline_; + // The handle the last recorded deferred bind captured; the first draw after + // the slot fills or swaps re-records with the new handle. + VkPipeline current_guest_graphics_pipeline_handle_ = VK_NULL_HANDLE; VkPipeline current_external_graphics_pipeline_; VkPipeline current_external_compute_pipeline_; From 036fedb3e7c8bd6586d32d6a0801f0645440b5c4 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 13:57:16 +0100 Subject: [PATCH 31/39] [Vulkan] Resolve the descriptor image layout from the view actually bound --- .../xenia/gpu/vulkan/vulkan_texture_cache.cc | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc index 21427ba498..370f47acd4 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_texture_cache.cc @@ -638,19 +638,33 @@ void VulkanTextureCache::RequestTextures(uint32_t used_texture_mask) { VkImageLayout VulkanTextureCache::GetActiveBindingImageLayout( uint32_t fetch_constant_index, xenos::FetchOpDimension dimension, bool is_signed) const { + // Resolved the same way GetActiveBindingOrNullImageView resolves the view, + // fallbacks included: the layout must describe the image that view actually + // points at. Every fallback (null views, the flattened 3D-as-2D image) lives + // in SHADER_READ_ONLY_OPTIMAL; only a view backed by a promoted resolve + // destination is in GENERAL. const TextureBinding* binding = GetValidTextureBinding(fetch_constant_index); - if (binding && AreDimensionsCompatible(dimension, binding->key.dimension)) { - const Texture* texture = - (is_signed && texture_util::IsAnySignSigned(binding->swizzled_signs) && - IsSignedVersionSeparateForFormat(binding->key)) - ? binding->texture_signed - : binding->texture; - if (texture && static_cast(texture)->usage() == - VulkanTexture::Usage::kResolveDestStorage) { - return VK_IMAGE_LAYOUT_GENERAL; - } - } - return VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + if (!binding || !AreDimensionsCompatible(dimension, binding->key.dimension)) { + return VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + if (dimension == xenos::FetchOpDimension::k2D && + binding->key.dimension == xenos::DataDimension::k3D) { + return VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + const VulkanTextureBinding& vulkan_binding = + vulkan_texture_bindings_[fetch_constant_index]; + if ((is_signed ? vulkan_binding.image_view_signed + : vulkan_binding.image_view_unsigned) == VK_NULL_HANDLE) { + return VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + const Texture* texture = + (is_signed && IsSignedVersionSeparateForFormat(binding->key)) + ? binding->texture_signed + : binding->texture; + return texture && static_cast(texture)->usage() == + VulkanTexture::Usage::kResolveDestStorage + ? VK_IMAGE_LAYOUT_GENERAL + : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } VkImageView VulkanTextureCache::GetActiveBindingOrNullImageView( From 68e78ca924eb054ae869a6caa5fd9bc9d00b32e1 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 13:57:17 +0100 Subject: [PATCH 32/39] [Vulkan] Give transient storage-image descriptors a pool of their own type --- .../gpu/vulkan/vulkan_command_processor.cc | 29 +++++++++++++++---- .../gpu/vulkan/vulkan_command_processor.h | 3 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc index ad4507c49c..7e92705ad8 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.cc @@ -176,6 +176,10 @@ constexpr VkDescriptorPoolSize VulkanCommandProcessor::kDescriptorPoolSizeStorageBuffer = { VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, kLinkedTypeDescriptorPoolSetCount}; +constexpr VkDescriptorPoolSize + VulkanCommandProcessor::kDescriptorPoolSizeStorageImage = { + VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, kLinkedTypeDescriptorPoolSetCount}; + // 2x descriptors for texture images because of unsigned and signed bindings. constexpr VkDescriptorPoolSize VulkanCommandProcessor::kDescriptorPoolSizeTextures[2] = { @@ -205,6 +209,12 @@ VulkanCommandProcessor::VulkanCommandProcessor( ->vulkan_device(), &kDescriptorPoolSizeStorageBuffer, 1, kLinkedTypeDescriptorPoolSetCount), + transient_descriptor_allocator_storage_image_( + static_cast( + graphics_system->provider()) + ->vulkan_device(), + &kDescriptorPoolSizeStorageImage, 1, + kLinkedTypeDescriptorPoolSetCount), transient_descriptor_allocator_textures_( static_cast( graphics_system->provider()) @@ -3389,14 +3399,22 @@ VkDescriptorSet VulkanCommandProcessor::AllocateSingleTransientDescriptor( SingleTransientDescriptorLayout::kStorageBufferCompute || transient_descriptor_layout == SingleTransientDescriptorLayout::kStorageBufferFragment; + bool is_storage_image = + transient_descriptor_layout == + SingleTransientDescriptorLayout::kStorageImageFragment; ui::vulkan::LinkedTypeDescriptorSetAllocator& transient_descriptor_allocator = - is_storage_buffer ? transient_descriptor_allocator_storage_buffer_ - : transient_descriptor_allocator_uniform_buffer_; + is_storage_image + ? transient_descriptor_allocator_storage_image_ + : (is_storage_buffer + ? transient_descriptor_allocator_storage_buffer_ + : transient_descriptor_allocator_uniform_buffer_); VkDescriptorPoolSize descriptor_count; - descriptor_count.type = is_storage_buffer - ? VK_DESCRIPTOR_TYPE_STORAGE_BUFFER - : VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + descriptor_count.type = + is_storage_image + ? VK_DESCRIPTOR_TYPE_STORAGE_IMAGE + : (is_storage_buffer ? VK_DESCRIPTOR_TYPE_STORAGE_BUFFER + : VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER); descriptor_count.descriptorCount = 1; descriptor_set = transient_descriptor_allocator.Allocate( GetSingleTransientDescriptorLayout(transient_descriptor_layout), @@ -6751,6 +6769,7 @@ void VulkanCommandProcessor::ClearTransientDescriptorPools() { } single_transient_descriptors_used_.clear(); transient_descriptor_allocator_storage_buffer_.Reset(); + transient_descriptor_allocator_storage_image_.Reset(); transient_descriptor_allocator_uniform_buffer_.Reset(); } diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h index 7abeb819fc..be82ceab83 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_command_processor.h @@ -848,11 +848,14 @@ class VulkanCommandProcessor final : public CommandProcessor { static constexpr uint32_t kLinkedTypeDescriptorPoolSetCount = 32768; static const VkDescriptorPoolSize kDescriptorPoolSizeUniformBuffer; static const VkDescriptorPoolSize kDescriptorPoolSizeStorageBuffer; + static const VkDescriptorPoolSize kDescriptorPoolSizeStorageImage; static const VkDescriptorPoolSize kDescriptorPoolSizeTextures[2]; ui::vulkan::LinkedTypeDescriptorSetAllocator transient_descriptor_allocator_uniform_buffer_; ui::vulkan::LinkedTypeDescriptorSetAllocator transient_descriptor_allocator_storage_buffer_; + ui::vulkan::LinkedTypeDescriptorSetAllocator + transient_descriptor_allocator_storage_image_; std::deque single_transient_descriptors_used_; std::array, size_t(SingleTransientDescriptorLayout::kCount)> From ece7166418440e1b73f0c6750fed15c1e7b13635 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 15:20:23 +0100 Subject: [PATCH 33/39] [GPU] Read shader storage from the start of the file, not from its end --- .../cpp/xenia/src/xenia/gpu/shader_storage.h | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/shader_storage.h b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/shader_storage.h index 70c2c91d62..c92bd676c1 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/shader_storage.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/shader_storage.h @@ -230,8 +230,22 @@ class ShaderStorageWriter { auto pipeline_storage_file_path = shader_storage_shareable_root / fmt::format("{:08X}{}", title_id, pipeline_config.file_suffix); + { + std::error_code size_ec; + auto pipeline_size = + std::filesystem::file_size(pipeline_storage_file_path, size_ec); + XELOGI("Shader storage: pipeline file {} ({} bytes on disk)", + xe::path_to_utf8(pipeline_storage_file_path), + size_ec ? uint64_t(0) : uint64_t(pipeline_size)); + } pipeline_storage_file_ = xe::filesystem::OpenFile(pipeline_storage_file_path, "a+b"); + // Append-update streams start at end-of-file on bionic, so the header read + // below would hit EOF and condemn a perfectly good file. Writes still go to + // the end regardless of where the read cursor is. + if (pipeline_storage_file_) { + xe::filesystem::Seek(pipeline_storage_file_, 0, SEEK_SET); + } if (!pipeline_storage_file_) { XELOGE( "Failed to open the pipeline storage file for writing, persistent " @@ -244,11 +258,27 @@ class ShaderStorageWriter { const uint32_t pipeline_storage_version_swapped = xe::byte_swap(pipeline_config.version); PipelineStorageFileHeader pipeline_header; - if (fread(&pipeline_header, sizeof(pipeline_header), 1, - pipeline_storage_file_) && + std::memset(&pipeline_header, 0, sizeof(pipeline_header)); + const bool pipeline_header_read = + fread(&pipeline_header, sizeof(pipeline_header), 1, + pipeline_storage_file_) != 0; + const bool pipeline_header_ok = + pipeline_header_read && pipeline_header.magic == kPipelineStorageMagic && pipeline_header.magic_api == pipeline_config.api_magic && - pipeline_header.version_swapped == pipeline_storage_version_swapped) { + pipeline_header.version_swapped == pipeline_storage_version_swapped; + if (!pipeline_header_ok) { + XELOGI( + "Shader storage: pipeline header rejected (read={}, magic {:016X} " + "want {:016X}, api {:08X} want {:08X}, version {:08X} want {:08X}) - " + "storage is being reset", + pipeline_header_read, uint64_t(pipeline_header.magic), + uint64_t(kPipelineStorageMagic), uint32_t(pipeline_header.magic_api), + uint32_t(pipeline_config.api_magic), + uint32_t(xe::byte_swap(pipeline_header.version_swapped)), + uint32_t(pipeline_config.version)); + } + if (pipeline_header_ok) { // Valid header, read pipeline descriptions. xe::filesystem::Seek(pipeline_storage_file_, 0, SEEK_END); int64_t pipeline_storage_told_end = @@ -305,6 +335,9 @@ class ShaderStorageWriter { GetShaderStorageFilePath(cache_root, title_id); shader_storage_file_ = xe::filesystem::OpenFile(shader_storage_file_path, "a+b"); + if (shader_storage_file_) { + xe::filesystem::Seek(shader_storage_file_, 0, SEEK_SET); + } if (!shader_storage_file_) { XELOGE( "Failed to open the guest shader storage file for writing, " @@ -316,9 +349,28 @@ class ShaderStorageWriter { } // Load shaders from storage. + { + std::error_code size_ec; + auto shader_size = + std::filesystem::file_size(shader_storage_file_path, size_ec); + XELOGI("Shader storage: shader file {} ({} bytes on disk)", + xe::path_to_utf8(shader_storage_file_path), + size_ec ? uint64_t(0) : uint64_t(shader_size)); + } size_t shaders_loaded = 0; ShaderStorageFileHeader shader_header; - if (ValidateShaderStorageHeader(shader_storage_file_, shader_header)) { + std::memset(&shader_header, 0, sizeof(shader_header)); + const bool shader_header_ok = + ValidateShaderStorageHeader(shader_storage_file_, shader_header); + if (!shader_header_ok) { + XELOGI( + "Shader storage: shader header rejected (magic {:08X} want {:08X}, " + "version {:08X} want {:08X}) - storage is being reset", + uint32_t(shader_header.magic), uint32_t(kShaderStorageMagic), + uint32_t(xe::byte_swap(shader_header.version_swapped)), + uint32_t(ShaderStoredHeader::kVersion)); + } + if (shader_header_ok) { uint64_t shader_storage_valid_bytes = ReadShaderEntries( shader_storage_file_, [&](xenos::ShaderType type, const uint32_t* ucode_dwords, @@ -330,6 +382,17 @@ class ShaderStorageWriter { } return false; }); + { + std::error_code size_ec; + auto shader_size = + std::filesystem::file_size(shader_storage_file_path, size_ec); + if (!size_ec && uint64_t(shader_size) != shader_storage_valid_bytes) { + XELOGW( + "Shader storage: parsed {} of {} bytes, truncating the rest as " + "corrupt", + shader_storage_valid_bytes, uint64_t(shader_size)); + } + } xe::filesystem::TruncateStdioFile(shader_storage_file_, shader_storage_valid_bytes); } else { From 368ceceebc896a2ff76951cb555b3058cf260581 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 15:20:23 +0100 Subject: [PATCH 34/39] [GPU] Signal shader storage completion when it completes, and gate pipeline pre-creation --- .../src/main/cpp/xenia/src/xenia/emulator.cc | 13 +++++-------- .../src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc | 12 ++++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/emulator.cc b/emulator-core/src/main/cpp/xenia/src/xenia/emulator.cc index 65b7ab6de7..54b7e59802 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/emulator.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/emulator.cc @@ -2599,17 +2599,14 @@ X_STATUS Emulator::CompleteLaunch(const std::filesystem::path& path, } } - // Initialize shader storage asynchronously - pipeline compilation happens in - // background while the game goes through its normal startup (loading screens, - // intro videos, etc.). With async_shader_compilation enabled, draws are - // skipped until pipelines are ready, so this is safe. By the time actual - // gameplay starts, most cached pipelines should be compiled. + // Asynchronous, completing while the guest boots. Not blocking: the launch + // runs on the UI thread, which this call's blocking mode would park on the + // command processor while the command processor still needs it serviced. if (graphics_system_) { on_shader_storage_initialization(true); graphics_system_->InitializeShaderStorage( - cache_root_, title_id_.value(), false/*, - [this]() { on_shader_storage_initialization(false); }*/); - on_shader_storage_initialization(false); + cache_root_, title_id_.value(), false, + [this]() { on_shader_storage_initialization(false); }); } auto main_thread = kernel_state_->LaunchModule(module); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc index 312688dc32..254549498a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_pipeline_cache.cc @@ -59,6 +59,13 @@ namespace shaders { #include "xenia/gpu/shaders/bytecode/vulkan_spirv/ucode_interpreter_vs.h" } // namespace shaders +DEFINE_bool( + pipeline_storage_precreate, true, + "Create the pipelines described by previous runs when a title starts, so " + "they do not compile mid-gameplay. Off still loads and reuses the stored " + "shaders.", + "GPU"); + DEFINE_int32( vulkan_pipeline_creation_threads, -1, "Number of threads used for graphics pipeline creation. -1 to calculate " @@ -2988,6 +2995,11 @@ void VulkanPipelineCache::InitializeShaderStorage( } // Create pipelines from stored descriptions. + if (!cvars::pipeline_storage_precreate) { + XELOGI("Pipeline cache: skipping pre-creation of {} stored pipelines", + pipeline_stored_descriptions.size()); + pipeline_stored_descriptions.clear(); + } if (!pipeline_stored_descriptions.empty()) { uint64_t pipeline_creation_start = xe::Clock::QueryHostTickCount(); From ff972fe8d4ce94160dbea4d32f6ecfd6feed6d9f Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 16:47:28 +0100 Subject: [PATCH 35/39] [Android] Keep the startup update check quiet while it is in cooldown --- .../java/xendroid/compose/MainActivity.kt | 12 +- docs/a830-gmem-msaa-plan.md | 345 ++++++++++++++++++ 2 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 docs/a830-gmem-msaa-plan.md diff --git a/app/src/main/java/xendroid/compose/MainActivity.kt b/app/src/main/java/xendroid/compose/MainActivity.kt index 9827b92d17..c363e66fb9 100644 --- a/app/src/main/java/xendroid/compose/MainActivity.kt +++ b/app/src/main/java/xendroid/compose/MainActivity.kt @@ -21,8 +21,6 @@ import xendroid.compose.ui.AppNavHost import xendroid.compose.ui.theme.xendroidTheme import xendroid.compose.settings.ConfigStore import xendroid.compose.settings.seedTouchOverlayDefault -import xendroid.compose.updater.CooldownDialog -import xendroid.compose.updater.getRemainingCooldown import xendroid.compose.updater.LatestVersionDialog import xendroid.compose.updater.UpdateDialog import xendroid.compose.updater.UpdateResult @@ -88,7 +86,6 @@ class MainActivity : ComponentActivity() { 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 } @@ -123,14 +120,7 @@ class MainActivity : ComponentActivity() { ) } - is UpdateResult.Cooldown -> { - CooldownDialog( - remainingMillis = result.remainingMillis, - onDismiss = { updateResult = null } - ) - } - - null -> {} + is UpdateResult.Cooldown, null -> {} } } } diff --git a/docs/a830-gmem-msaa-plan.md b/docs/a830-gmem-msaa-plan.md new file mode 100644 index 0000000000..9e21ceec93 --- /dev/null +++ b/docs/a830-gmem-msaa-plan.md @@ -0,0 +1,345 @@ +# a830 GMEM: findings and the plan to fix it + +2026-08-13. Device: AYN Odin 3 (Adreno 830, `3458d5f`), driver: `mesa-tu8` +`gen8-merge-upstream` + uncommitted diagnostics, title: Blue Dragon (4D5307DF), +config: `turnip_debug=''` (GMEM on), instrumentation via +`debug.mesa.tu.perf.sampler=1`. + +The goal is fixed: **MSAA render passes tile correctly into GMEM on a830, GMEM +on by default, faster than sysmem.** MSAA→sysmem is measured slower than plain +sysmem and is not a fallback — it routes exactly the passes that would benefit +from tiling away from it. There is no acceptable end state short of the real +fix; interim constraints are allowed only if derived from a measured hardware +envelope, and only while labelled as such. + +## Findings ledger + +### Established (measured, reproducible) + +| Fact | Evidence | +|---|---| +| Fault signature | `kgsl waittimestamp` EPROTO(71) → EDEADLK(35), then emulator SIGABRT on the GPU Commands thread. Hard fault, no page fault (July capture: `gpufaults` col2, zero pagefaults). | +| a830 GMEM is 12 MB | `kgsl_gmem_probe`: `gmem_sizebytes=12582912`, aperture VA `0x4000000`. Kernel-supplied; `TU_GMEM` overrides. | +| Single-sample tiled pass is fine | fb 1280x2048, 1 att (VkFormat 97 = R16G16B16A16_SFLOAT, cpp 8, samples 1), runs indefinitely. | +| The faulting pass is the 2x MSAA one | fb 720x1824, tile0 768x480, att[0] R16G16B16A16_SFLOAT samples=2 cpp=16 off=0 end=5760 KB; att[1] D24S8 samples=2 cpp=8 off=6832128 end=9552 KB. Faults ≤ ~400 ms after first appearance, every run. | +| GMEM layout fits | usable(gmem) = 10032 KB after 2256 KB CCU/VPC reservations; high water 9552 KB. Layout is self-consistent (`off = gmem_pixels × cpp`, `427008 × 24 = 10008 KB`). | +| MSAA works in sysmem | `sysmem` arm: no fault (and the game otherwise runs). So: not a general MSAA bug. | +| Single-sample works in GMEM | So: not a general GMEM bug. The target is exactly the intersection: **RB/CCU writing multisampled pixels into GMEM during rasterization**. | + +### Bisection arms — all transfer paths cleared + +One binary, `debug.mesa.tu.xd.msaa` selects the arm: + +| Arm | Result | Clears | +|---|---|---| +| (normal) | faults | — | +| `sysmem` | **no fault** | confirms MSAA-in-GMEM is the trigger | +| `nobin` (hw binning off) | faults | VSC/visibility path | +| `nostore` (skip GMEM store/resolve) | faults | store path incl. gen8 `va = gmem_offset` TILE6_2 code | +| `noload` (skip GMEM load) | faults | load path | + +### Refuted — do not revisit + +- **VSC prim-stream overflow.** Real (16448→32832→65600 unbounded doubling), + fixed by 16× initial pitch — overflow then zero everywhere **and it still + faulted**. Keep the pitch fix; it is not the cause. +- **Attachment past usable GMEM.** The earlier "EXCEEDS USABLE" flags were an + instrumentation bug (cpp already includes samples; multiplying again + double-counts — `tu_pass.cc:1026`). + +### Found incidentally (real bugs, fix regardless) + +1. **Unsigned underflow in `fd6_gmem_cache.h __calc_gmem_cache_offsets`**: five + reservations subtracted with no floor; GMEM < ~2.26 MB wraps offsets to + ~4 GB. Assert-builds abort in `__RB_CCU_CACHE_CNTL` (`depth_offset` + field-fit); release builds program garbage CCU bases silently. Audit a810 + (576 KB entry) against its reservations. Upstreamable guard. +2. **VSC initial prim pitch** far too small for this workload; growth-by- + doubling overruns before converging (overrun happens before detection). +3. **a830 has no per-device GMEM/CCU tuning** — only a8xx part without one; + inherits `a8xx_gen1` (16 KB/CCU color EIGHTH, 256 KB/CCU depth FULL) on a + 6-CCU/3-slice part. a825's own entry admits its values are guesses. + +### Invalid run — must not be counted + +The `TU_GMEM=4194304` run: APK was reinstalled mid-test (path hash changed), +validation + RenderDoc layers present, crash had none of the fault signature +(no tiled pass logged, no kgsl errno). Re-run after re-baselining. The 2 MB +run only proved the underflow bug (2 MB < 2256 KB reservations). + +## Remaining mechanism space + +All three data-movement paths are cleared, sysmem-MSAA and GMEM-1x both work. +Candidates, ranked: + +**H1 — GMEM offset/sample scaling on a8xx.** If gen8 RB/CCU applies a sample +multiplier to per-pixel GMEM addressing where the driver has *already* baked +samples into byte offsets (cpp = blocksize × samples), att[1]'s effective +address ≈ 6.5 MB × 2 = 13 MB > the 12 MB aperture → hard fault, no page fault. +Fits every observation: att[0] (off 0) safe at any scaling, 1x passes safe, +transfer paths (separately programmed) safe. Predicts: fault vanishes when +offsets shrink. + +**H2 — CCU cache geometry wrong for a830.** Untuned inherited config on 6 +CCUs; MSAA doubles CCU traffic per pixel; wrong fraction/size/offset lets the +CCU write outside its reserved window during rendering. Predicts: fault is +*independent* of GMEM clamp (reservations don't scale with `TU_GMEM`). + +**H3 — a8xx register field semantics** (RB GMEM base units, per-sample stride +encoding) subtly wrong in the gen8 fork for the MSAA case. Overlaps H1; +distinguished only by reading actual register values. + +`TU_GMEM=8388608` is a clean H1-vs-H2 discriminator: it shrinks attachment +offsets (~3.9 MB for att[1]) without touching CCU geometry. + +## The plan + +### Phase 0 — re-baseline (one launch) + +The APK changed under us. Before any new evidence: confirm which build is +installed, check leftover GPU-debug-layer settings (`settings list global | +grep gpu`), then reproduce the **baseline fault** on this APK (arms unset, +`TU_GMEM` unset) with the full signature: `XD gmem:` MSAA pass logged → +EPROTO → SIGABRT. No baseline, no experiments. + +### Phase 1 — pin the mechanism (one launch per step) + +1. `setprop debug.mesa.tu.gmem 8388608`, MSAA normal. + - **No fault → H1.** Bisect 8→12 MB to the exact ceiling; the boundary + arithmetic (which offset × what factor crosses what limit) names the + broken semantics directly. + - **Fault → H2/H3.** Build an arm that programs a830's GMEM-mode CCU + config with sysmem-mode fractions/sizes (and/or FULL fraction), re-test. +2. Scope sweep (cheap, same binary): does 4x MSAA fault? Does the fault need + the 64bpp color target, or does RGBA8 2x also fault? Narrows the fix and + feeds the reproducer matrix. +3. `.rd` capture of the faulting submit (`TU_DEBUG=rd` via property, + `FD_RD_DUMP_PATH` to app files dir): read back `RB_MRT[*]_BASE_GMEM`, + `RB_CCU_CACHE_CNTL`, sample-count fields; diff the same pass captured in + sysmem mode. This turns hypotheses into register values. + +### Phase 2 — ground truth (parallel, off the launch loop) + +- **Standalone Vulkan reproducer** (NDK, `/data/local/tmp`, forces GMEM): + one render pass, {RGBA8, RGBA16F} × {1,2,4} samples × {D24S8, none} × + sweep of extents. Seconds per iteration instead of a game launch; the fix + is proven against this matrix, then confirmed in the game — not the other + way round. +- **KGSL snapshot → cffdec bridge.** crashdec parses drm/msm devcoredumps, + not KGSL binary snapshots (magic `0x504D0002`) — that dead end is already + documented. The snapshot section format is in the downstream kernel + headers; a ~300-line parser feeding mesa's existing cffdec gives the exact + PM4 packet and register state at fault. We already hold two undecoded + snapshots (fault139, a650 GoW hang) and can capture fresh ones. This is a + permanent capability, not scaffolding. +- **Mine downstream kgsl gen8 config** (LineageOS sm8750-modules, + `adreno_gen8_*` core configs — same source that decoded the gpufaults + columns) for a830's real CCU/GMEM constants → a genuine per-device + `freedreno_devices.py` entry to replace the inherited guesswork, whichever + hypothesis wins. + +### Phase 3 — the fix, and only the fix + +- Implement the proven mechanism where it belongs: device config + (`freedreno_devices.py`), RB emission (`tu_cmd_buffer.cc`), or a8xx pack + semantics — gated to a8xx, no quirks, no game checks. +- If the mechanism turns out to be a hard hardware envelope (offsets/sizes + MSAA can address in GMEM), the constraint goes into the **GMEM layout + math** so MSAA passes still tile within the envelope (smaller + `gmem_pixels`/divisor for MSAA passes) — *keeping* GMEM for MSAA, not + routing around it. That is the only acceptable shape of "workaround", and + only with the measured ceiling written next to it. +- Land separately, in order: (1) underflow guard + a810 audit, (2) VSC + pitch/growth fix, (3) a830 device config from downstream values, (4) the + MSAA fix. Each upstreamable in shape. Diagnostics (`tu_xd_msaa_mode`, + noload/nostore/nobin arms) are deleted once the mechanism is pinned — + they are scaffolding, not product. + +### Validation gate (all required) + +- Reproducer matrix green across formats/sample counts. +- Blue Dragon 30+ min soak, GMEM on — the historical "~4 min under load" + device-lost class must not reappear. +- Halo 3 spot check (known a830 Turnip GPU-stall title). +- fps A/B: GMEM must beat sysmem on the MSAA-heavy scenes, else the fix is + not done. +- Instrumentation off (`debug.mesa.tu.perf.sampler` unset): zero residual + cost, identical behaviour. + +## Device/session state as of this doc + +- `Turnip_Gen8_Debug` slot: diagnostic build (MSAA selector + 16× VSC pitch); + meta.json description stale. +- Properties: `debug.mesa.tu.perf.sampler=1` set; `debug.mesa.tu.gmem` + cleared; `debug.mesa.tu.xd.msaa` cleared. +- Blue Dragon config: GMEM on, driver pinned to the debug slot. +- mesa-tu8 uncommitted: VSC pitch (tu_device.cc), arms + instrumentation + (tu_cmd_buffer.cc, tu_clear_blit.cc, tu_xd_instr.h). +- Two prior wrong root-cause calls (VSC, layout overflow) are why Phase 3 + requires a register-level proof and a reproducer before any fix lands. + +--- + +## Addendum — 2026-08-13, after the smalltile/CCU arms + +### New results + +- **`smalltile` (H1 arm): faults.** Divisor forced to max: tile0 768x480 → + 192x96, spans 5760/2880 KB → 288/144 KB, pipes reconfigured 1x4 → 2x10. + Refutes per-tile footprint, tile dimensions, tile count and pipe geometry + as triggers. **It does not test H1 proper:** `gmem_offset[]` is indexed by + layout only — the divisor shrinks spans but att[1]'s base offset stayed at + 6832128. Base-offset magnitude remains untested. +- **First real CCU geometry readout** (`XD ccu:`): gmem color off=11845632 + frac=EIGHTH size=16 KB, depth off=10272768 frac=FULL size=256 KB; sysmem + color off=10420224 frac=FULL size=128 KB, depth off=8847360 frac=FULL + size=256 KB. Internally consistent, nothing out of range. +- **The TU_GMEM=8388608 run reinterpreted.** Not an H1 test (it pushed both + passes out of GMEM — usable 5936 KB < 7392 KB needed by the 1x pass) and + weak H2 evidence at best. What it actually showed: with **every pass in + sysmem** and only the derived CCU/VPC region placement moved (all values + in-range and encodable), the GPU still device-lost. Baseline sysmem works. + So there is a second latent sensitivity: gen8 CCU/VPC **placement rules** + are load-bearing in ways the fork's math does not capture, sysmem included. + That may share a root with the MSAA-pass fault or be separate; either way + the fork's model of gen8 GMEM-region placement is incomplete. + +### Correction to the headline conclusion + +"MSAA-in-GMEM is the culprit" is **overstated**. The workload only ever +produces two tiled pass shapes: + +| | works | faults | +|---|---|---| +| samples | 1 | **2** | +| attachments | 1 (color only) | **2 (color + D24S8)** | +| depth in GMEM | no | **yes** | +| non-zero base offset | no (att at 0) | **yes (att[1] at 6832128)** | + +Four variables, perfectly confounded — every 3D pass in this game is +MSAA+depth, and the only non-MSAA tiled pass is color-only at offset 0. The +`sysmem` deflection keyed on `samples > 1`, but it would have "confirmed" any +of the four. Established, precisely: *this pass shape faults during GMEM +rasterization; which property triggers it is not isolated.* This matters for +the fix and for any upstream report. + +### Revised next steps (in order) + +1. **Run the armed CCU arms** (`a825`, then `full`) — zero build cost, + closes or confirms narrow-H2 (fraction/size pairs). `XD ccu:` line + verifies each arm took. +2. **New arm `swap`:** reverse GMEM allocation order for the faulting pass + (depth at 0, color at ~3.4 MB). One launch splits "non-zero base offset" + from "attachment identity": fault follows the offset → H1'; follows the + depth attachment → depth-GMEM programming; vanishes → ordering/alignment. +3. **Register-level evidence — after which no more arms.** `TU_DEBUG=rd` + via property (`debug.mesa.tu.debug`), `FD_RD_DUMP_PATH` to an + app-writable dir; capture the faulting submit, a 1x-GMEM submit, and the + same pass in sysmem. Decode with the already-configured host tools; + diff every GMEM-related register (RB_MRT gmem base, RB_DEPTH_*_GMEM, + CCU cache bases, sample state) against the known layout numbers. Tests + H1' units/scaling, depth-vs-color programming, and H3 encodings in one + artifact. +4. **Standalone reproducer is now the critical path for naming the + trigger** — the game cannot decompose the confound; the reproducer can: + {1x,2x} × {color-only, color+depth} × {RGBA8, RGBA16F} × {second + attachment at small/large offset}, forced-GMEM, seconds per iteration. +5. **Downstream kgsl gen8 mining** (sm8750 `adreno_gen8_*`): CCU/GMEM + constants and any per-slice placement constraints (a830 = 6 CCUs over 3 + slices; the fork lays regions out as one contiguous block — whether + hardware requires per-slice distribution is exactly the kind of rule the + 8 MB sysmem failure hints at). Feeds the real device entry regardless. +6. KGSL snapshot→cffdec parser: keep on the list; decide after (3) whether + the rd captures already pin the faulting packet. + +--- + +## Addendum 2 — layout space exhausted, moving to register capture + +Results after the first addendum, all arms verified applied via `XD gmem:`: + +- `ccu=a825` (color HALF/128K, depth HALF/128K): **faults.** With the default + (EIGHTH/16K + FULL/256K) also faulting, two very different CCU geometries + fail identically — fraction/size tuning is dead as a standalone cause. +- `swap` (depth at offset 0, color at 3416064): **faults.** Attachment + identity is irrelevant. Also kills the naive "base × samples > aperture" + H1 variant (3.4 MB × 2 is comfortably in-aperture). +- `lowoffs` (allocator capped: att[0] 0..960 KB, att[1] 1008..1488 KB, + CCU/VPC untouched): **faults.** The end-of-region scaling variant is + refuted too — offset magnitude is fully cleared. + +**Conclusion: 2x MSAA rasterization into GMEM faults on a830 regardless of +every layout parameter the driver controls** (footprint, tile/pipe geometry, +binning, load, store, CCU pairs, attachment identity, offset magnitude). +The defect is in MSAA-specific GMEM register/command state on gen8. + +Architecture note (verified in-tree): a830 (0x4405) is a840's silicon family +(0x44050A31), identical 6-CCU/3-slice/96-align topology; a825/829 are 0x4403 +2-slice parts. a830 is misfiled under `a8xx_gen1` — though gen1 ≡ gen2 in all +GMEM-mode values, so the misfile alone does not explain the fault. The +fraction-encoding collision (3 = EIGHTH pre-gen2 vs THREE_QUARTER gen2+) on +a830's color CCU remains an open question for the rd dump to answer +(`RB_CCU_CACHE_CNTL` raw value). + +Next artifact — not another arm: rd capture is armed on-device +(`debug.mesa.tu.debug=rd`, `debug.mesa.fd.rd.dump=enable`, dump path in the +app's files dir). Compare, register by register: the faulting MSAA-GMEM +submit vs the working 1x-GMEM submit vs the same MSAA pass in sysmem. +Host cffdump is built (`mesa-tu8/build-host-tools/src/freedreno/decode/`). + +--- + +## Addendum 3 — register-level capture decoded: it's a pipeline wedge, not a CP fault + +Artifacts: rd dump of the faulting run (submits 3519-3523 + control 1350 pulled +to the session scratchpad), kernel fault header via plain `dmesg` (no root): + +``` +adreno-gen8-gmu: MISC: GPU hang detected (hang; zero page faults) +BR: ib1 0x40431A4000/002f ib2 0x40431CE120/0000 +BV: ib1 0x40431A4000/002f ib2 0 +``` + +Decoded (trimmed-rd windows; full cffdump is impractically slow, the +window trick decodes in seconds): + +- The freeze point is in **submit 3522's first render-pass prologue** — a + **DIRECT_RENDER (sysmem) pass targeting the MSAA framebuffer** (scissor + 719x1823, `RB_BUFFER_CNTL` all-SYSMEM, GMEM dims zeroed, LRZ disabled with + poison values), ~300 dwords after a completed 9-dword SP-state IB2, frozen + mid context-register writes between `CP_THREAD_CONTROL(BR | + CONCURRENT_BIN_DISABLE)` and the never-reached `CP_SET_MARKER + (RM6_DIRECT_RENDER)` + first `CP_INDIRECT_BUFFER`. +- Plain register writes cannot block the CP; both threads freezing there is + **backpressure**: the RB/CCU pipeline is already wedged by earlier work + when the prologue's register traffic fills the queue. +- Therefore the wedge is created by the **GMEM MSAA rendering in the + preceding submissions** and only *detected* at the next prologue. This + explains the entire arm ledger at once: every layout/identity/offset arm + faulted (none removed GMEM MSAA work), the sysmem deflection alone + survived (it removed all of it), and every TU_DEBUG flag crashed the same + (none of them remove GMEM MSAA rendering wholesale). +- The workload **mixes GMEM and DIRECT_RENDER passes against the same MSAA + framebuffer** (autotune decides per pass), with + `THREAD_CONTROL(CONCURRENT_BIN_DISABLE)` transitions between modes — the + wedge sits somewhere in GMEM-MSAA work + mode transition. +- gen8 runs a **resolve-group protocol** (per-blit `CCU_RESOLVE` events + closed by `CCU_END_RESOLVE_GROUP`). Every observed `RB_RESOLVE_OPERATION` + has `LAST = 0`, in working and faulting passes alike; the working 1x + control shows 12 group-ends vs 10 resolves. Depth ops carry + `SAMPLE_0 | DEPTH | BUFFER_ID=0x8`. + +### Next + +1. Window-decode the full GMEM MSAA pass (s3519/s3521) and the + GMEM→DIRECT_RENDER transition; count resolves vs group-ends per pass. +2. Source-map `tu_emit_resolve_group` / `CCU_END_RESOLVE_GROUP` emission and + the THREAD_CONTROL transition rules; look for what a830 (a840-family) + expects vs what the gen1-filed path emits. +3. Only then: arm candidate fixes (group-close/serialization variants around + MSAA GMEM stores) — cheap A/B, one launch each. +4. Verify freeze-PC stability across 2-3 more faults via dmesg alone (no rd + overhead needed). + +Toolkit gained: `dmesg` fault headers without root; trimmed-rd windowed +decode (seconds vs minutes); rd files are gzip despite the .rd name. From d434ef516ecf67e579b2d517a4625fdc9aabc7a4 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 21:00:27 +0100 Subject: [PATCH 36/39] [CPU] Select the right lane halves when folding a constant halfword permute --- docs/a830-gmem-msaa-plan.md | 79 +++++++++++++++++++ .../main/cpp/xenia/src/xenia/cpu/hir/value.cc | 16 +--- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/a830-gmem-msaa-plan.md b/docs/a830-gmem-msaa-plan.md index 9e21ceec93..5abfa9f1ca 100644 --- a/docs/a830-gmem-msaa-plan.md +++ b/docs/a830-gmem-msaa-plan.md @@ -343,3 +343,82 @@ window trick decodes in seconds): Toolkit gained: `dmesg` fault headers without root; trimmed-rd windowed decode (seconds vs minutes); rd files are gzip despite the .rd name. + +--- + +## Addendum 4 — the wedge is localized; shared-constants prime suspect + +Full structure of the frozen submission (s3522, mapped packet-by-packet): +one GMEM MSAA pass (binning marker 0x2 → BV section → BR with concurrent +binning ENABLED → 4 tiles of [0x84 draws 0x85 tile-store 0x7]) → three +post-pass marker-0x8 sections → one DIRECT pass executed OK → freeze in the +second DIRECT prologue, ~250 dwords after the GMEM pass. + +Decoded the small IBs: + +- The per-tile store IB is well-formed: color store (MSAA_TWO, gmem 0) + + depth store (gmem 0x684000) + CCU_END_RESOLVE_GROUP. (Correctness sidebar: + MSAA depth store carries SAMPLE_0 — likely drops sample 1; not the hang.) +- **The marker-0x8 post-pass stubs are SP_SHARED_CONSTANT_GFX writes**, and + the kernel fault header's "BR last IB2" (0x40431ce120) is exactly one of + them: the GPU's last completed act was a shared-constant write while BV + was live in concurrent-binning mode. + +Precedent: the a650 4-squares bug — shared-const execution-overlap race, +WFI-proven ([[turnip-a650-4squares-investigation]]). XenDroid auto-applies +`TU_DEBUG=push_consts_per_stage` ONLY for a6xx (`vulkan_instance.cc` checks +gpu_model/100==6); a830 never gets it. + +Two free discriminators (properties only, dmesg as readout — freeze PC +moves/vanishes if the mechanism is hit): + +1. `debug.mesa.tu.debug=push_consts_per_stage` ← ARMED +2. `debug.mesa.tu.debug=nocb` (no concurrent binning) — next if (1) is clean + or unclear; separates shared-consts from BV/BR concurrency generally. + +If (1) fixes it: the driver-side fix is routing shared constants per-stage +(or fencing shared-const updates against the concurrent pipe) on a8xx — +plus XenDroid extending its auto-flag to gen8. If (2) fixes it and (1) +does not: the fix is in the concurrent-binning rendezvous protocol. + +--- + +## Addendum 5 — protocol arms exhausted; synthetic repro is clean; moving to stream replay + +Further negative results (each verified applied): + +- `rg=sl` (singleton resolve groups + LAST=1 on every op): **faults.** +- `vulkan_in_pass_resolve=false` (xenia's local_read resolves off): **faults.** +- `ccu=full`, `a825`: fault. `ccucntl` override: **impossible** — RB_CCU_CNTL + is CP-protected on gen8 (`CP | Protected mode error | WRITE | addr=0x08e07`); + the kernel fixes full-concurrent resolve mode for all userspace including + the blob. TU_DEBUG noconcurrentresolves/unresolves are silent no-ops on + A8XX (`tu6_init_static_regs` writes RB_CCU_CNTL only `if (CHIP == A7XX)`). +- `nocb` IS honored (drirc allow_concurrent_binning=false) — concurrency + genuinely tested and cleared. + +**Standalone reproducer built and run on-device** (direct HAL-module loading — +the Android driver exports only `HMI`, no ICD symbols): +18 cases, all CLEAN in both forced-GMEM and default-heuristic modes: +{1x,2x,4x} × {color-only, +D24S8} × {RGBA8, RGBA16F} × {store, dontcare}, +plus vkCmdResolveImage after the pass, vkCmdClearColorImage between passes, +second pass per cmdbuf, 3-deep pipelined submissions, and real VB/IB indexed +draws (VFD traffic). **Plain MSAA GMEM rendering works on a830.** The wedge +requires something in xenia's stream beyond all of the above. + +**Next: replay the actual captured faulting stream.** mesa's +`src/freedreno/decode/replay.c` supports KGSL: allocate one huge buffer +(kgsl VA base is deterministic - probe measured 0x4000000000 every time), +place captured buffers at original-address offsets, submit the captured IB1s +via IOCTL_KGSL_GPU_COMMAND. Game buffers span base..+~1.1GB → needs a ~2GB +fake address space. Building mesa's replay for Android fights libdrm/ +libarchive deps; a minimal purpose-built kgsl replayer (rd parser already +written, kgsl ioctls already exercised by the probes) is the fast path. +Then bisect the faulting IB1 packet-by-packet (truncate/NOP progressively) - +the wedge packet falls out mechanically, no more hypotheses needed. + +Artifacts: reproducer + probes in session scratchpad `repro/`; rd captures +of the faulting frame pulled and decoded; vendor kernel tree at +~/mesa-turnip/graphics-kernel (gen8_0_0 = the 12MB a830-class entry; +gen8_3_0 in the vendor gpulist is a 576KB-GMEM small SKU, NOT a830 — +corrects the old memory note). diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/hir/value.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/hir/value.cc index 1255510a90..2f3d9a050a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/hir/value.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/hir/value.cc @@ -883,20 +883,10 @@ void Value::Permute(Value* src1, Value* src2, TypeName type) { shuffle_bytes(src1->constant.v128, perm, shuf1); shuffle_bytes(src2->constant.v128, perm, shuf2); - uint8_t mask = 0; + // Index < 8 selects the first source. for (int i = 0; i < 8; i++) { - if (perm_ctrl.i16[i] == 0) { - mask |= 1 << (7 - i); - } - } - - // Blend: select from shuf1 where mask bit is set, shuf2 otherwise - for (int i = 0; i < 8; i++) { - if (mask & (1 << i)) { - constant.v128.u16[i] = shuf1.u16[i]; - } else { - constant.v128.u16[i] = shuf2.u16[i]; - } + constant.v128.u16[i] = + perm_ctrl.i16[i] == 0 ? shuf1.u16[i] : shuf2.u16[i]; } } else { From 0ac798c6e11706e4b053cc424438fb8e0520c33e Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 21:02:07 +0100 Subject: [PATCH 37/39] [CPU] Add a cvar-gated log of guest function entries, returns and fields --- .../src/xenia/cpu/backend/a64/a64_emitter.cc | 11 + .../src/xenia/cpu/backend/a64/a64_emitter.h | 1 + .../src/xenia/cpu/backend/a64/a64_tracers.cc | 192 ++++++++++++++++++ .../src/xenia/cpu/backend/a64/a64_tracers.h | 4 + .../main/cpp/xenia/src/xenia/cpu/cpu_flags.cc | 53 +++++ .../main/cpp/xenia/src/xenia/cpu/cpu_flags.h | 17 ++ .../xenia/src/xenia/cpu/ppc/ppc_translator.cc | 32 +-- 7 files changed, 280 insertions(+), 30 deletions(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.cc index e80d911cc0..b0e6ed992a 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.cc @@ -99,6 +99,9 @@ bool A64Emitter::Emit(GuestFunction* function, hir::HIRBuilder* builder, trace_data_ = &function->trace_data(); current_guest_function_ = function->address(); + log_guest_call_ = !cvars::log_guest_calls_at.empty() && + GuestAddressInList(cvars::log_guest_calls_at, + current_guest_function_); // Reset state. stack_size_ = StackLayout::GUEST_STACK_SIZE; @@ -200,6 +203,10 @@ bool A64Emitter::Emit(hir::HIRBuilder* builder, EmitFunctionInfo& func_info) { mov(x1, static_cast(current_guest_function_)); CallNative(reinterpret_cast(TraceFunctionEntry)); } + if (log_guest_call_) { + mov(x1, static_cast(current_guest_function_)); + CallNative(reinterpret_cast(LogGuestCallEntry)); + } // Allocate the epilog label (owned by label_cache_ for cleanup). auto epilog_label_ptr = new Label(); @@ -270,6 +277,10 @@ bool A64Emitter::Emit(hir::HIRBuilder* builder, EmitFunctionInfo& func_info) { mov(x1, static_cast(current_guest_function_)); CallNative(reinterpret_cast(TraceFunctionReturn)); } + if (log_guest_call_) { + mov(x1, static_cast(current_guest_function_)); + CallNative(reinterpret_cast(LogGuestCallReturn)); + } code_offsets.epilog = getSize(); // Pop stackpoint before leaving. diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.h b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.h index a273209050..bb70a1a754 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_emitter.h @@ -205,6 +205,7 @@ class A64Emitter : public Xbyak_aarch64::CodeGenerator { XexModule* guest_module_ = nullptr; uint64_t feature_flags_ = 0; uint32_t current_guest_function_ = 0; + bool log_guest_call_ = false; Xbyak_aarch64::Label* epilog_label_ = nullptr; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.cc index 091ff222a3..e45e135000 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.cc @@ -9,13 +9,21 @@ #include "xenia/cpu/backend/a64/a64_tracers.h" +#include +#include +#include #include +#include +#include +#include "xenia/base/byte_order.h" #include "xenia/base/logging.h" #include "xenia/base/vec128.h" #include "xenia/cpu/cpu_flags.h" #include "xenia/cpu/ppc/ppc_context.h" +#include "xenia/cpu/processor.h" #include "xenia/cpu/thread_state.h" +#include "xenia/memory.h" namespace xe { namespace cpu { @@ -121,6 +129,190 @@ void TraceFunctionReturn(void* raw_context, uint64_t function_address) { ppc_context->r[3]); } +namespace { + +struct GuestCallLogSlot { + std::atomic address{0}; + std::atomic hits{0}; + std::atomic last_value{~uint64_t(0)}; +}; + +constexpr size_t kGuestCallLogSlotCount = 8; +GuestCallLogSlot guest_call_entry_slots[kGuestCallLogSlotCount]; +GuestCallLogSlot guest_call_return_slots[kGuestCallLogSlotCount]; + +// One "rN+off[>off2][:count]" term. +struct GuestCallField { + std::string label; + uint32_t reg; + uint32_t offset; + bool indirect; + uint32_t indirect_offset; + uint32_t words; +}; + +constexpr uint32_t kMaxGuestCallFieldWords = 8; + +const std::vector& GetGuestCallFields() { + static const std::vector fields = []() { + std::vector parsed; + const std::string& list = cvars::log_guest_call_fields; + for (size_t pos = 0; pos < list.size();) { + size_t end = list.find(',', pos); + if (end == std::string::npos) { + end = list.size(); + } + const std::string entry = list.substr(pos, end - pos); + pos = end + 1; + const char* cursor = entry.c_str(); + while (*cursor == ' ' || *cursor == '\t') { + ++cursor; + } + const char* start = cursor; + if (*cursor == 'r' || *cursor == 'R') { + ++cursor; + } + char* parse_end = nullptr; + GuestCallField field = {}; + field.reg = uint32_t(std::strtoul(cursor, &parse_end, 10)); + if (parse_end == cursor || field.reg >= 32 || *parse_end != '+') { + continue; + } + cursor = parse_end + 1; + field.offset = uint32_t(std::strtoul(cursor, &parse_end, 16)); + if (parse_end == cursor) { + continue; + } + cursor = parse_end; + if (*cursor == '>') { + field.indirect = true; + field.indirect_offset = uint32_t(std::strtoul(cursor + 1, &parse_end, + 16)); + cursor = parse_end; + } + field.words = 1; + if (*cursor == ':') { + field.words = uint32_t(std::strtoul(cursor + 1, &parse_end, 10)); + cursor = parse_end; + field.words = std::min(std::max(field.words, 1u), + kMaxGuestCallFieldWords); + } + field.label.assign(start, cursor); + parsed.push_back(std::move(field)); + } + return parsed; + }(); + return fields; +} + +bool ReadGuestWord(ppc::PPCContext* ppc_context, uint32_t address, + uint32_t* out_value) { + auto* memory = ppc_context->processor ? ppc_context->processor->memory() + : nullptr; + if (!memory) { + return false; + } + auto* heap = memory->LookupHeap(address); + uint32_t protect = 0; + if (!heap || !heap->QueryProtect(address, &protect) || + !(protect & kMemoryProtectRead)) { + return false; + } + *out_value = + xe::load_and_swap(ppc_context->virtual_membase + address); + return true; +} + +std::string FormatGuestCallFields(ppc::PPCContext* ppc_context) { + std::string formatted; + for (const auto& field : GetGuestCallFields()) { + uint32_t address = + static_cast(ppc_context->r[field.reg]) + field.offset; + if (field.indirect) { + uint32_t pointer = 0; + if (!ReadGuestWord(ppc_context, address, &pointer)) { + formatted += fmt::format(" [{}]@{:08X}=", field.label, + address); + continue; + } + address = pointer + field.indirect_offset; + } + formatted += fmt::format(" [{}]@{:08X}=", field.label, address); + for (uint32_t word = 0; word < field.words; ++word) { + uint32_t value = 0; + if (ReadGuestWord(ppc_context, address + word * 4, &value)) { + formatted += fmt::format("{}{:08X}", word ? "," : "", value); + } else { + formatted += fmt::format("{}", word ? "," : ""); + break; + } + } + } + return formatted; +} + +GuestCallLogSlot* AcquireGuestCallLogSlot(GuestCallLogSlot* slots, + uint32_t address) { + for (size_t i = 0; i < kGuestCallLogSlotCount; ++i) { + uint32_t owner = slots[i].address.load(std::memory_order_acquire); + if (owner == address) { + return &slots[i]; + } + if (!owner) { + uint32_t expected = 0; + if (slots[i].address.compare_exchange_strong(expected, address, + std::memory_order_acq_rel) || + expected == address) { + return &slots[i]; + } + } + } + return nullptr; +} + +} // namespace + +void LogGuestCallEntry(void* raw_context, uint64_t function_address) { + auto ppc_context = reinterpret_cast(raw_context); + const uint32_t address = static_cast(function_address); + auto* slot = AcquireGuestCallLogSlot(guest_call_entry_slots, address); + const uint32_t hits = + slot ? slot->hits.fetch_add(1, std::memory_order_relaxed) + 1 : 1; + if (cvars::log_guest_calls_limit && hits > cvars::log_guest_calls_limit) { + return; + } + XELOGI( + "guest call {:08X} #{} thread {:04X} lr={:08X} r3={:08X} r4={:08X} " + "r5={:08X} r11={:08X}{}", + address, hits, ppc_context->thread_id, + static_cast(ppc_context->lr), + static_cast(ppc_context->r[3]), + static_cast(ppc_context->r[4]), + static_cast(ppc_context->r[5]), + static_cast(ppc_context->r[11]), + FormatGuestCallFields(ppc_context)); +} + +void LogGuestCallReturn(void* raw_context, uint64_t function_address) { + auto ppc_context = reinterpret_cast(raw_context); + const uint32_t address = static_cast(function_address); + const uint64_t value = ppc_context->r[3]; + auto* slot = AcquireGuestCallLogSlot(guest_call_return_slots, address); + const uint32_t hits = + slot ? slot->hits.fetch_add(1, std::memory_order_relaxed) + 1 : 1; + // A changed return value is the interesting event for a readiness gate, so + // it outlives the line budget. + const bool changed = + slot && + slot->last_value.exchange(value, std::memory_order_relaxed) != value; + if (!changed && cvars::log_guest_calls_limit && + hits > cvars::log_guest_calls_limit) { + return; + } + XELOGI("guest ret {:08X} #{} thread {:04X} r3={:08X}", address, hits, + ppc_context->thread_id, static_cast(value)); +} + void TraceString(void* raw_context, const char* str) { auto ppc_context = reinterpret_cast(raw_context); IPRINT(str); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.h b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.h index dc9b11a90c..6a3abdc012 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/backend/a64/a64_tracers.h @@ -40,6 +40,10 @@ void SetTraceFuncEnabled(bool value); void TraceFunctionEntry(void* raw_context, uint64_t function_address); void TraceFunctionReturn(void* raw_context, uint64_t function_address); + +// Emitted only for the addresses named by log_guest_calls_at. +void LogGuestCallEntry(void* raw_context, uint64_t function_address); +void LogGuestCallReturn(void* raw_context, uint64_t function_address); void TraceString(void* raw_context, const char* str); void TraceContextLoadI8(void* raw_context, uint64_t offset, uint8_t value); diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc index 5342d2700c..002b00de03 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.cc @@ -9,6 +9,8 @@ #include "xenia/cpu/cpu_flags.h" +#include + DEFINE_string(cpu, "any", "Does nothing. CPU backend [any, x64].", "CPU"); DEFINE_string( @@ -25,6 +27,22 @@ DEFINE_string(dump_functions_at, "", DEFINE_bool(disassemble_functions, false, "Disassemble functions during generation.", "CPU"); +DEFINE_string(log_guest_calls_at, "", + "Comma-separated guest addresses (hex) whose entry and return " + "are logged with the guest argument registers. arm64 only.", + "CPU"); +DEFINE_string(log_guest_call_fields, "", + "Comma-separated guest words to dump on every log_guest_calls_at " + "entry. Each term is +, optionally followed by " + "> to dereference it and re-offset, and by : " + "for several words, e.g. \"r4+30,r4+4>F8,r4+4>B0:4\".", + "CPU"); +DEFINE_uint32(log_guest_calls_limit, 1000, + "Entry and return lines logged per address by " + "log_guest_calls_at; 0 is unlimited. A return whose value " + "differs from the last one is always logged.", + "CPU"); + DEFINE_bool(trace_functions, false, "Generate tracing for function statistics.", "CPU"); DEFINE_bool(trace_function_coverage, false, @@ -68,3 +86,38 @@ DEFINE_bool(break_condition_truncate, true, "truncate value to 32-bits", "CPU"); DEFINE_bool(break_on_debugbreak, true, "int3 on JITed __debugbreak requests.", "CPU"); + +namespace xe { +namespace cpu { + +bool GuestAddressInList(const std::string& list, uint32_t address) { + for (size_t pos = 0; pos < list.size();) { + size_t end = list.find(',', pos); + if (end == std::string::npos) { + end = list.size(); + } + std::string entry = list.substr(pos, end - pos); + pos = end + 1; + size_t first = entry.find_first_not_of(" \t"); + if (first == std::string::npos) { + continue; + } + entry = entry.substr(first, entry.find_last_not_of(" \t") - first + 1); + if (entry.size() > 2 && entry[0] == '0' && + (entry[1] == 'x' || entry[1] == 'X')) { + entry = entry.substr(2); + } + if (entry.size() > 4 && (entry.compare(0, 4, "sub_") == 0 || + entry.compare(0, 4, "SUB_") == 0)) { + entry = entry.substr(4); + } + uint32_t parsed = 0; + if (std::sscanf(entry.c_str(), "%X", &parsed) == 1 && parsed == address) { + return true; + } + } + return false; +} + +} // namespace cpu +} // namespace xe diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h index 36240408bf..7c9e3cc439 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/cpu_flags.h @@ -9,6 +9,9 @@ #ifndef XENIA_CPU_CPU_FLAGS_H_ #define XENIA_CPU_CPU_FLAGS_H_ +#include +#include + #include "xenia/base/cvar.h" DECLARE_string(cpu); @@ -18,6 +21,10 @@ DECLARE_string(load_module_map); DECLARE_string(dump_functions_at); DECLARE_bool(disassemble_functions); +DECLARE_string(log_guest_calls_at); +DECLARE_string(log_guest_call_fields); +DECLARE_uint32(log_guest_calls_limit); + DECLARE_bool(trace_functions); DECLARE_bool(trace_function_coverage); DECLARE_bool(trace_function_references); @@ -38,4 +45,14 @@ DECLARE_bool(break_condition_truncate); DECLARE_bool(break_on_debugbreak); +namespace xe { +namespace cpu { + +// Matches an address against a comma-separated list of hex guest addresses, +// tolerating "0x" and "sub_" prefixes. +bool GuestAddressInList(const std::string& list, uint32_t address); + +} // namespace cpu +} // namespace xe + #endif // XENIA_CPU_CPU_FLAGS_H_ diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc index 3d8da38015..40024e2f10 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/cpu/ppc/ppc_translator.cc @@ -400,36 +400,8 @@ bool PPCTranslator::Translate(GuestFunction* function, } bool PPCTranslator::IsDumpTarget(uint32_t address) { - if (cvars::dump_functions_at.empty()) { - return false; - } - const std::string& list = cvars::dump_functions_at; - for (size_t pos = 0; pos < list.size();) { - size_t end = list.find(',', pos); - if (end == std::string::npos) { - end = list.size(); - } - std::string entry = list.substr(pos, end - pos); - pos = end + 1; - size_t first = entry.find_first_not_of(" \t"); - if (first == std::string::npos) { - continue; - } - entry = entry.substr(first, entry.find_last_not_of(" \t") - first + 1); - if (entry.size() > 2 && (entry[0] == '0') && - (entry[1] == 'x' || entry[1] == 'X')) { - entry = entry.substr(2); - } - if (entry.size() > 4 && - (entry.compare(0, 4, "sub_") == 0 || entry.compare(0, 4, "SUB_") == 0)) { - entry = entry.substr(4); - } - uint32_t parsed = 0; - if (std::sscanf(entry.c_str(), "%X", &parsed) == 1 && parsed == address) { - return true; - } - } - return false; + return !cvars::dump_functions_at.empty() && + GuestAddressInList(cvars::dump_functions_at, address); } void PPCTranslator::DumpTargetFunction(GuestFunction* function) { From 465a22a2d49a21c383c56727a5a7d5c09cf2a7c5 Mon Sep 17 00:00:00 2001 From: rfandango Date: Thu, 13 Aug 2026 22:15:10 +0100 Subject: [PATCH 38/39] [Kernel] Refuse guest sockets while the XNet session layer cannot reach a peer --- .../main/cpp/xenia/src/xenia/kernel/xam/xam_net.cc | 13 +++++++++++++ .../src/main/cpp/xenia/src/xenia/kernel/xsocket.h | 1 + 2 files changed, 14 insertions(+) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_net.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_net.cc index 0011d65728..3c42d9d497 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_net.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/xam_net.cc @@ -19,6 +19,13 @@ #include "xenia/kernel/xthread.h" #include "xenia/xbox.h" +DEFINE_bool(network_enabled, false, + "Let titles open host sockets. The XNet address and session layer " + "is stubbed, so guest traffic cannot reach a peer; enabling this " + "only binds real host ports and lets titles enter online paths " + "that may block indefinitely.", + "Kernel"); + #ifdef XE_PLATFORM_WIN32 // NOTE: must be included last as it expects windows.h to already be included. #ifndef _WINSOCK_DEPRECATED_NO_WARNINGS @@ -668,6 +675,12 @@ dword_result_t NetDll_socket_entry(dword_t caller, dword_t af, dword_t type, XELOGI("NetDll_socket: af={}, type={}, protocol={}", af.value(), type.value(), protocol.value()); + if (!cvars::network_enabled) { + XThread::SetLastError(uint32_t(X_WSAError::X_WSAENETDOWN)); + XELOGI("NetDll_socket: refused, network_enabled is off"); + return -1; + } + XSocket* socket = new XSocket(kernel_state()); X_STATUS result = socket->Initialize(XSocket::AddressFamily((uint32_t)af), XSocket::Type((uint32_t)type), diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xsocket.h b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xsocket.h index 4dbeb6a2ab..884668aae7 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xsocket.h +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xsocket.h @@ -33,6 +33,7 @@ enum class X_WSAError : uint32_t { X_WSAEINVAL = 0x2726, X_WSAENOTSOCK = 0x2736, X_WSAEMSGSIZE = 0x2738, + X_WSAENETDOWN = 0x2742, }; struct XSOCKADDR { From 7b33819ebb24cfe19bfdc34b3f1fcd446e37339f Mon Sep 17 00:00:00 2001 From: rfandango Date: Fri, 14 Aug 2026 09:12:44 +0100 Subject: [PATCH 39/39] [GPU] Keep in-pass EDRAM resolves off by default, on for the TDU titles --- emulator-core/src/main/cpp/xenia/src/xenia/game_quirks.cc | 2 ++ .../xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/game_quirks.cc b/emulator-core/src/main/cpp/xenia/src/xenia/game_quirks.cc index e44ec1fe33..4723ac45eb 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/game_quirks.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/game_quirks.cc @@ -32,6 +32,8 @@ static const Quirk kQuirks[] = { "FMV slice desync with inlined restores"}, {0x4D5307D1, "spirv_multiply_zero_test_on_bits", true, "ir3 cannot compile fmadz"}, + {0x494707D4, "vulkan_in_pass_resolve", true, "in-pass resolves tuned here"}, + {0x49470804, "vulkan_in_pass_resolve", true, "in-pass resolves tuned here"}, {0x4E4D083A, "spirv_multiply_zero_test_on_bits", true, "ir3 cannot compile fmadz"}, }; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc index a20ea144fa..88f2eb0687 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/gpu/vulkan/vulkan_render_target_cache.cc @@ -87,7 +87,7 @@ DEFINE_bool( "break. Disable for A/B measurement of the break count.", "Vulkan"); DEFINE_bool( - vulkan_in_pass_resolve, true, + vulkan_in_pass_resolve, false, "Prepare color attachments for in-pass EDRAM resolves with " "VK_KHR_dynamic_rendering_local_read: RENDERING_LOCAL_READ image layout " "and input-attachment usage on color render targets. Requires dynamic "