From edbe2fb8ec7f4ff6d313b3ec938a9001d014502b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 09:07:42 +0530 Subject: [PATCH 01/10] FEX and shader caches do not hold a game launch on a dead or slow network The FEX cache check runs on every launch (no 6-hour stamp) and waits at most 3 s for the lookup. Downloads stop at once when cancelled, fail after 15 s without data, end after 180 s in total, and can be skipped with a Skip button on the loading dialog and the booting splash. The shader pool lookup waits at most 3 s and its download and replay can be skipped the same way. --- .../java/app/gamenative/api/CodeCacheApi.kt | 20 +- .../java/app/gamenative/api/SupportApi.kt | 7 +- .../shadercache/FossilizeManager.kt | 77 ++++++-- .../main/java/app/gamenative/ui/PluviaMain.kt | 2 + .../ui/component/dialog/LoadingDialog.kt | 26 +++ .../gamenative/ui/components/BootingSplash.kt | 72 +++++++ .../java/app/gamenative/utils/LaunchSkip.kt | 29 +++ .../java/app/gamenative/utils/NetworkUtils.kt | 59 ++++++ .../FexCodeCacheDependency.kt | 178 +++++++++++++----- app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ro/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 25 files changed, 417 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/LaunchSkip.kt diff --git a/app/src/main/java/app/gamenative/api/CodeCacheApi.kt b/app/src/main/java/app/gamenative/api/CodeCacheApi.kt index 7f47362891..15d6b48eab 100644 --- a/app/src/main/java/app/gamenative/api/CodeCacheApi.kt +++ b/app/src/main/java/app/gamenative/api/CodeCacheApi.kt @@ -1,6 +1,8 @@ package app.gamenative.api import app.gamenative.utils.FexImages +import app.gamenative.utils.Net +import app.gamenative.utils.executeCancellable import com.github.luben.zstd.ZstdInputStream import okhttp3.Request import org.json.JSONArray @@ -78,14 +80,20 @@ object CodeCacheApi { * applies to ([haveSha256] equals the cache's patchFrom), only the zstd patch is fetched and * applied with the old file as dictionary; otherwise the full gzip is streamed. Either way the * result is inflated through a temp file, hashed, and renamed only when size and sha256 match. + * Cancelling the coroutine stops the transfer at once; [onProgress] gets the inflated fraction (0..1). */ - fun download(cache: HostedCache, target: File, haveSha256: String? = null): Boolean { + suspend fun download( + cache: HostedCache, + target: File, + haveSha256: String? = null, + onProgress: ((Float) -> Unit)? = null, + ): Boolean { val tmp = File(target.parentFile, "${target.name}.part") try { val usePatch = haveSha256 != null && haveSha256 == cache.patchFrom && target.isFile val url = if (usePatch) "${cache.url}?have=$haveSha256" else cache.url val request = Request.Builder().url(url).get().build() - GameNativeApi.httpClient.newCall(request).execute().use { response -> + Net.launchDownload.newCall(request).executeCancellable { response -> if (!response.isSuccessful) throw IOException("HTTP ${response.code}") val isPatch = response.header("X-Patch-From") == haveSha256 && usePatch val body = response.body!!.byteStream() @@ -100,6 +108,7 @@ object CodeCacheApi { } val digest = MessageDigest.getInstance("SHA-256") var total = 0L + var reportedPercent = -1 input.use { inflated -> FileOutputStream(tmp).use { output -> val buffer = ByteArray(256 * 1024) @@ -109,6 +118,13 @@ object CodeCacheApi { digest.update(buffer, 0, n) output.write(buffer, 0, n) total += n + if (onProgress != null && cache.size > 0) { + val percent = (total * 100 / cache.size).toInt().coerceIn(0, 100) + if (percent != reportedPercent) { + reportedPercent = percent + onProgress(percent / 100f) + } + } } } } diff --git a/app/src/main/java/app/gamenative/api/SupportApi.kt b/app/src/main/java/app/gamenative/api/SupportApi.kt index b6c76370a9..74a109d42a 100644 --- a/app/src/main/java/app/gamenative/api/SupportApi.kt +++ b/app/src/main/java/app/gamenative/api/SupportApi.kt @@ -2,6 +2,7 @@ package app.gamenative.api import android.os.SystemClock import app.gamenative.utils.DebugReportUtils +import app.gamenative.utils.executeCancellable import androidx.compose.runtime.mutableStateOf import app.gamenative.PrefManager import kotlinx.coroutines.CancellationException @@ -13,6 +14,7 @@ import kotlinx.coroutines.withContext import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody +import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody @@ -911,14 +913,15 @@ object SupportApi { destination: File, maxBytes: Long = Long.MAX_VALUE, onProgress: ((Float) -> Unit)? = null, + client: OkHttpClient = transferClient, ): ApiResult = withContext(Dispatchers.IO) { if (!url.startsWith("https://")) return@withContext ApiResult.HttpError(400, "bad_url") try { destination.parentFile?.mkdirs() val job = coroutineContext.job val request = Request.Builder().url(url).get().build() - val result: Pair = transferClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) return@use null to response.code + val result: Pair = client.newCall(request).executeCancellable { response -> + if (!response.isSuccessful) return@executeCancellable null to response.code val total = response.body.contentLength() if (total > maxBytes) throw IOException("download of $total bytes exceeds $maxBytes") val digest = MessageDigest.getInstance("SHA-256") diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index 47544b6543..2f6db9e61a 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -9,6 +9,8 @@ import app.gamenative.api.ApiResult import app.gamenative.api.ShaderCacheApi import app.gamenative.api.SupportApi import app.gamenative.events.AndroidEvent +import app.gamenative.utils.LaunchSkip +import app.gamenative.utils.Net import com.posthog.PostHog import com.winlator.container.Container import com.winlator.core.FileUtils @@ -21,12 +23,13 @@ import java.io.File import java.io.IOException import java.security.MessageDigest import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.delay +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -69,7 +72,7 @@ object FossilizeManager { private const val FOZ_HEADER_BYTES = 16L private const val MAX_RETAINED_BYTES = 200L * 1024 * 1024 private const val MAX_TRANSFER_BYTES = 256L * 1024 * 1024 - private const val POOL_LOOKUP_TIMEOUT_MS = 15_000L + private const val POOL_LOOKUP_TIMEOUT_MS = 3_000L private const val POOL_DOWNLOAD_TIMEOUT_MS = 3 * 60 * 1000L private const val REPLAY_WALL_CLOCK_MS = 10 * 60 * 1000L private const val REPLAY_STALL_SECONDS = 30 @@ -81,6 +84,7 @@ object FossilizeManager { private val PROGRESS_LINE = Regex("Overall (\\d+) / (\\d+)") private val uploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val launchWorkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val uploadMutex = Mutex() @Volatile @@ -212,9 +216,14 @@ object FossilizeManager { return file } - /** Downloads the newest pool for this container and replays it. Blocking; never throws. Stops early once [isCancelled]. */ + /** + * Downloads the newest pool for this container and replays it. Blocking; never throws. Stops early once [isCancelled] + * or once the player presses Skip. + */ @JvmStatic fun prepareBeforeLaunch(context: Context, container: Container, launcher: BionicProgramLauncherComponent, isCancelled: () -> Boolean) { + val skipped = AtomicBoolean(false) + val shouldStop = { isCancelled() || skipped.get() } try { if (!isEnabled(container)) return val replayBin = File(context.applicationInfo.nativeLibraryDir, REPLAY_BIN) @@ -226,7 +235,7 @@ object FossilizeManager { Timber.tag(TAG).i("No shader cache key for %s", container.id) return } - val meta = refreshPool(context, container, key, isCancelled)?.takeIf { poolFile(container, key).isFile } + val meta = refreshPool(context, container, key, shouldStop, skipped)?.takeIf { poolFile(container, key).isFile } val poolFile = meta?.let { poolFile(container, key) } ?: localPoolFile(container).takeIf { it.isFile } ?: return val version = meta?.optLong("version")?.toString() ?: "local-${poolFile.length()}-${poolFile.lastModified()}" val stamp = "${key.fileName}|$version|${driverIdentity(container)}" @@ -235,18 +244,27 @@ object FossilizeManager { Timber.tag(TAG).d("Replay of %s already done for version %s, skipping", key.fileName, version) return } - if (isCancelled()) return - replay(context, container, launcher, key, poolFile, version, meta?.optLong("entries") ?: 0L, replayBin, stamp, isCancelled) + if (shouldStop()) return + replay(context, container, launcher, key, poolFile, version, meta?.optLong("entries") ?: 0L, replayBin, stamp, shouldStop, skipped) } catch (t: Throwable) { Timber.tag(TAG).e(t, "Shader cache preparation failed") + } finally { + LaunchSkip.withdraw() } } - private fun refreshPool(context: Context, container: Container, key: ShaderCacheKey, isCancelled: () -> Boolean): JSONObject? { + private fun refreshPool( + context: Context, + container: Container, + key: ShaderCacheKey, + shouldStop: () -> Boolean, + skipped: AtomicBoolean, + ): JSONObject? { val metaFile = poolMetaFile(container, key) val local = runCatching { JSONObject(metaFile.readText()) }.getOrNull() ?.takeIf { poolFile(container, key).isFile } - val remote = runUnlessCancelled(POOL_LOOKUP_TIMEOUT_MS, isCancelled) { ShaderCacheApi.fetchPool(key) } + // Abandoned rather than cancelled: the lookup can include an account token refresh that has to finish. + val remote = awaitWork(POOL_LOOKUP_TIMEOUT_MS, shouldStop, cancelOnStop = false) { ShaderCacheApi.fetchPool(key) } val pool = if (remote is ApiResult.Success) remote.data ?: return local else return local if (local != null && local.optLong("version") >= pool.version) return local if (pool.size > MAX_TRANSFER_BYTES) { @@ -254,10 +272,17 @@ object FossilizeManager { return local } PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) + LaunchSkip.offer { skipped.set(true) } val target = poolFile(container, key) val partial = File(target.parentFile, target.name + ".part") - val result = runUnlessCancelled(POOL_DOWNLOAD_TIMEOUT_MS, isCancelled) { - SupportApi.downloadSigned(pool.url, partial, if (pool.size > 0) pool.size else MAX_TRANSFER_BYTES) + // Cancelled and joined so the .part file is no longer written once this returns. + val result = awaitWork(POOL_DOWNLOAD_TIMEOUT_MS, shouldStop, cancelOnStop = true) { + SupportApi.downloadSigned( + pool.url, + partial, + if (pool.size > 0) pool.size else MAX_TRANSFER_BYTES, + client = Net.launchDownload, + ) } val hash = if (result is ApiResult.Success) result.data else null if (hash == null || !hash.equals(pool.sha256, ignoreCase = true)) { @@ -280,16 +305,28 @@ object FossilizeManager { return meta } - /** Runs [block] with a timeout, cancelling it once [isCancelled]; null if it timed out or was cancelled. */ - private fun runUnlessCancelled(timeoutMs: Long, isCancelled: () -> Boolean, block: suspend () -> T): T? = runBlocking { - val work = async(Dispatchers.IO) { withTimeoutOrNull(timeoutMs) { block() } } - while (work.isActive && !isCancelled()) delay(CANCEL_POLL_MS) - if (work.isActive) { - work.cancel() - null - } else { - work.await() + /** + * Runs [block] off the calling thread and waits for it for at most [timeoutMs], or until [shouldStop]. Null if the + * wait ended without a result or [block] failed; never throws. When the wait ends early, [block] is cancelled and + * joined if [cancelOnStop], and otherwise left to finish on its own with its result dropped. + */ + private fun awaitWork(timeoutMs: Long, shouldStop: () -> Boolean, cancelOnStop: Boolean, block: suspend () -> T): T? = try { + val work = launchWorkScope.async { block() } + runBlocking { + withTimeoutOrNull(timeoutMs) { + while (work.isActive && !shouldStop()) withTimeoutOrNull(CANCEL_POLL_MS) { work.join() } + } + when { + work.isActive -> { + if (cancelOnStop) work.cancelAndJoin() + null + } + else -> work.await() + } } + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Shader cache network step failed") + null } private fun driverIdentity(container: Container): String = @@ -306,6 +343,7 @@ object FossilizeManager { replayBin: File, stamp: String, isCancelled: () -> Boolean, + skipped: AtomicBoolean, ) { val env = launcher.buildShellEnvVars(null, arrayOf("FOSSILIZE", "FOSSILIZE_DUMP_PATH", "FOSSILIZE_DUMP_PATH_READ_ONLY")) ?: return @@ -324,6 +362,7 @@ object FossilizeManager { val workDir = poolDir(container) val started = System.currentTimeMillis() PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) + LaunchSkip.offer { skipped.set(true) } var command = listOf(replayBin.absolutePath) var outcome = runReplay(command + args + "--progress", env, workDir, started, context, isCancelled) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 662ce1432d..a264be98cf 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -154,6 +154,7 @@ import app.gamenative.utils.UpdateChecker import app.gamenative.utils.UpdateInfo import app.gamenative.utils.UpdateInstaller import app.gamenative.utils.LaunchDependencies +import app.gamenative.utils.LaunchSkip import app.gamenative.workshop.WorkshopManager import app.gamenative.workshop.compatibility.SlayTheSpireModTheSpireCompatibility import com.google.android.play.core.splitcompat.SplitCompat @@ -1521,6 +1522,7 @@ fun PluviaMain( visible = state.loadingDialogVisible, progress = state.loadingDialogProgress, message = state.loadingDialogMessage, + onSkip = LaunchSkip.action.collectAsStateWithLifecycle().value, ) MessageDialog( diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt index 5be9a2a346..6310687e79 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/LoadingDialog.kt @@ -10,12 +10,15 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import app.gamenative.R import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.theme.ThemedDialogSurface import kotlin.math.min @@ -31,6 +34,7 @@ fun LoadingDialog( onDismissRequest: () -> Unit = {}, progress: Float, message: String = "Loading...", + onSkip: (() -> Unit)? = null, ) { when { visible -> { @@ -61,6 +65,15 @@ fun LoadingDialog( } else { LinearProgressIndicator(color = PluviaTheme.progressColor, trackColor = PluviaTheme.sliderTrackColor(ProgressIndicatorDefaults.linearTrackColor)) } + if (onSkip != null) { + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + onClick = onSkip, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(R.string.launch_skip)) + } + } } } } @@ -78,3 +91,16 @@ private fun Preview_LoadingDialog() { ) } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_LoadingDialog_Skip() { + PluviaTheme { + LoadingDialog( + visible = true, + progress = .4f, + message = "Downloading code cache...", + onSkip = {}, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt index 079dd8a872..eba5ad9732 100644 --- a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt +++ b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt @@ -3,8 +3,10 @@ package app.gamenative.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.* +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import android.net.Uri import android.view.ViewGroup import androidx.annotation.OptIn @@ -22,7 +24,9 @@ import androidx.compose.foundation.BorderStroke import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.Button import androidx.compose.material3.TextButton @@ -44,6 +48,7 @@ import app.gamenative.data.localizedWinBody import app.gamenative.data.localizedLoseBody import app.gamenative.data.isPlayable import app.gamenative.utils.BootAdView +import app.gamenative.utils.LaunchSkip import com.posthog.PostHog import kotlinx.coroutines.delay import androidx.compose.runtime.Composable @@ -81,6 +86,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.Player @@ -114,6 +120,8 @@ fun BootingSplash( onAbort: (() -> Unit)? = null, onDismissAd: ((optOut: Boolean) -> Unit)? = null, ) { + val skipAction = LaunchSkip.action.collectAsStateWithLifecycle().value + // Tips rotation (no animation cost, safe outside visibility check) val context = LocalContext.current val tips = remember(context) { @@ -363,6 +371,7 @@ fun BootingSplash( progress = progress, shimmerPosition = shimmerPosition, scrimColor = scrimColor, + onSkip = skipAction, ) } else Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -441,6 +450,8 @@ fun BootingSplash( textAlign = TextAlign.Center, ) + SplashSkipButton(onSkip = skipAction) + Spacer(modifier = Modifier.height(15.dp)) Spacer(modifier = Modifier.weight(0.3f)) @@ -622,6 +633,7 @@ private fun BootAdContent( progress: Float, shimmerPosition: Float, scrimColor: Color, + onSkip: (() -> Unit)?, ) { val context = LocalContext.current val textShadow = Shadow( @@ -787,6 +799,8 @@ private fun BootAdContent( color = Color.White.copy(alpha = 0.85f), textAlign = TextAlign.Center, ) + + SplashSkipButton(onSkip = onSkip) } if (showDismissSheet && onDismiss != null) { @@ -807,6 +821,50 @@ private fun BootAdContent( } } +// Never requests focus: a player pressing A while the game boots must not skip by accident. +@Composable +private fun SplashSkipButton( + onSkip: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = onSkip != null, + modifier = modifier, + enter = fadeIn(tween(250)) + expandVertically(tween(250), expandFrom = Alignment.Top), + exit = fadeOut(tween(200)) + shrinkVertically(tween(200), shrinkTowards = Alignment.Top), + ) { + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + val shape = RoundedCornerShape(50) + OutlinedButton( + onClick = { onSkip?.invoke() }, + interactionSource = interaction, + shape = shape, + border = BorderStroke(1.dp, Color.White.copy(alpha = if (focused) 0.7f else 0.3f)), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.Black.copy(alpha = if (focused) 0.65f else 0.45f), + contentColor = Color.White, + ), + contentPadding = PaddingValues(start = 18.dp, end = 12.dp), + modifier = Modifier + .padding(top = 12.dp) + .height(36.dp) + .focusRing(interaction, shape, width = 2.dp), + ) { + Text( + text = stringResource(R.string.launch_skip), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Medium), + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.SkipNext, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } +} + @Composable private fun BootAdDismissSheet( sponsored: Boolean, @@ -1221,3 +1279,17 @@ fun BootingSplashLandscapePreview() { ) } } + +@Preview(name = "BootingSplash - Skip button") +@Composable +private fun SplashSkipButtonPreview() { + PluviaTheme { + Box( + modifier = Modifier + .background(Color(0xFF101418)) + .padding(24.dp), + ) { + SplashSkipButton(onSkip = {}) + } + } +} diff --git a/app/src/main/java/app/gamenative/utils/LaunchSkip.kt b/app/src/main/java/app/gamenative/utils/LaunchSkip.kt new file mode 100644 index 0000000000..659908a61d --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/LaunchSkip.kt @@ -0,0 +1,29 @@ +package app.gamenative.utils + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The Skip button on the launch screens. A launch step that may take long and is not needed to + * start the game offers an action while it runs; the loading dialog and the booting splash show + * the button for as long as one is offered. + */ +object LaunchSkip { + private val current = MutableStateFlow<(() -> Unit)?>(null) + + /** The action of the step that can be skipped now, or null. */ + val action: StateFlow<(() -> Unit)?> = current + + /** Shows the button; [onSkip] runs once when it is pressed and the button goes away. */ + fun offer(onSkip: () -> Unit) { + lateinit var once: () -> Unit + once = { + if (current.compareAndSet(once, null)) onSkip() + } + current.value = once + } + + fun withdraw() { + current.value = null + } +} diff --git a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt index 856827f2b9..f7e22fa6aa 100644 --- a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt +++ b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt @@ -1,13 +1,25 @@ package app.gamenative.utils +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback import okhttp3.Dns import okhttp3.Dispatcher import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.Response import okhttp3.dnsoverhttps.DnsOverHttps import timber.log.Timber +import java.io.IOException import java.net.InetAddress +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import java.util.concurrent.TimeUnit object Net { @@ -48,6 +60,15 @@ object Net { .build() } + /** For downloads a game launch waits for: a link that stops delivering fails in seconds, not minutes. */ + val launchDownload: OkHttpClient by lazy { + http.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + } + fun httpForParallelDownloads(parallelDownloads: Int): OkHttpClient { val hostConcurrency = parallelDownloads.coerceIn(4, 32) val dispatcher = Dispatcher().apply { @@ -62,3 +83,41 @@ object Net { .build() } } + +/** + * Runs the call and hands the response to [block] on the IO dispatcher. Cancelling the coroutine + * cancels the call, so a wait for the server or a read that is stuck ends at once. + */ +suspend fun Call.executeCancellable(block: (Response) -> T): T { + val call = this + val response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { call.cancel() } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) = continuation.resumeWithException(e) + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) continuation.resume(response) else response.close() + } + }, + ) + } + return try { + withContext(Dispatchers.IO) { + val watcher = launch(start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + block(response) + } finally { + watcher.cancel() + } + } + } finally { + response.close() + } +} diff --git a/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt b/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt index 5f00e4f7d1..052028fb36 100644 --- a/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt +++ b/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt @@ -10,10 +10,19 @@ import app.gamenative.utils.ContainerUtils import app.gamenative.utils.FexCodeMaps import app.gamenative.utils.FexImages import app.gamenative.utils.HardwareUtils +import app.gamenative.utils.LaunchSkip import com.winlator.container.Container import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject import timber.log.Timber import java.io.File @@ -22,14 +31,17 @@ import java.io.File * before launch, so FEX loads translated code instead of compiling it. Caches are hosted per image * code identity (FexImages.codeSha256), FEX build and SoC; the server names each one the way FEX looks for it on this device * (image name plus file id from the PE header), so a stale file after a game update is ignored. - * The lookup is re-checked once per [RECHECK_MS]; failures never block the launch. + * The lookup runs on every launch but holds it for at most [LOOKUP_TIMEOUT_MS], and downloads, which + * the player can skip, for at most [DOWNLOAD_TIMEOUT_MS]; failures never block the launch. */ object FexCodeCacheDependency : LaunchDependency { private const val TAG = "FexCodeCache" private const val CACHE_DIR = ".wine/drive_c/users/xuser/AppData/Local/fex-emu/cache" private const val STEAM_DIR = ".wine/drive_c/Program Files (x86)/Steam" - private const val RECHECK_MS = 6L * 60 * 60 * 1000 + private const val INSTALLED_INDEX = ".gamenative/fex-cache-installed.json" + private const val LOOKUP_TIMEOUT_MS = 3_000L + private const val DOWNLOAD_TIMEOUT_MS = 3 * 60 * 1000L private const val MAX_IMAGES = 64 private const val MAX_IMAGE_BYTES = 512L * 1024 * 1024 private val STEAM_CLIENT_DLLS = listOf( @@ -41,15 +53,16 @@ object FexCodeCacheDependency : LaunchDependency { "gameoverlayrenderer.dll", ) + // A lookup that outlives the launch's wait keeps running here: it may be refreshing the account + // token, and cancelling that mid-flight can lose the rotated refresh token. + private val lookupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun appliesTo(container: Container, gameSource: GameSource, gameId: Int): Boolean = PrefManager.fexCodeCacheEnabled && "fexcore".equals(container.emulator, ignoreCase = true) - override fun isSatisfied(context: Context, container: Container, gameSource: GameSource, gameId: Int): Boolean { - // A pack without the offline compiler cannot load caches; nothing to do for it. - if (!FexCodeMaps.supportsCodeMaps(context, container)) return true - val stamp = stampFile(container) - return stamp.exists() && System.currentTimeMillis() - stamp.lastModified() < RECHECK_MS - } + // A pack without the offline compiler cannot load caches; nothing to do for it. + override fun isSatisfied(context: Context, container: Container, gameSource: GameSource, gameId: Int): Boolean = + !FexCodeMaps.supportsCodeMaps(context, container) override fun getLoadingMessage(context: Context, container: Container, gameSource: GameSource, gameId: Int): String = "Checking FEX code cache" @@ -64,7 +77,8 @@ object FexCodeCacheDependency : LaunchDependency { try { refresh(container, callbacks, gameSource, gameId) } catch (e: CancellationException) { - throw e + ensureActive() + Timber.tag(TAG).w(e, "Hosted cache check was cancelled") } catch (e: Exception) { Timber.tag(TAG).w(e, "Hosted cache check failed") } @@ -80,60 +94,134 @@ object FexCodeCacheDependency : LaunchDependency { val images = FexImages.describe(container.rootDir, imageFiles(container, gameSource, gameId)) if (images.isEmpty()) { Timber.tag(TAG).i("No images to look up") - } else { - val hosted = when (val result = CodeCacheApi.lookup(fexVersion, soc, images)) { - is ApiResult.Success -> result.data - is ApiResult.HttpError -> { - Timber.tag(TAG).w("Cache lookup failed: HTTP ${result.code} ${result.message}") - return - } - is ApiResult.NetworkError -> { - Timber.tag(TAG).w("Cache lookup failed: ${result.exception.message}") - return - } - }.filter { cache -> - // The name becomes a path in the prefix; anything but a plain file name is refused. - val name = cache.fileName - val plain = name.isNotBlank() && name != "." && name != ".." && name.none { it == '/' || it == '\\' || it == '\u0000' } - if (!plain) Timber.tag(TAG).w("Ignoring cache with file name ${cache.fileName}") - plain + return + } + val lookup = lookupScope.async { CodeCacheApi.lookup(fexVersion, soc, images) } + val result = withTimeoutOrNull(LOOKUP_TIMEOUT_MS) { lookup.await() } + if (result == null) { + Timber.tag(TAG).w("Cache lookup took over $LOOKUP_TIMEOUT_MS ms; launching with the local caches") + return + } + val hosted = when (result) { + is ApiResult.Success -> result.data + is ApiResult.HttpError -> { + Timber.tag(TAG).w("Cache lookup failed: HTTP ${result.code} ${result.message}") + return } - val dir = File(container.rootDir, CACHE_DIR) - if (!dir.isDirectory && !dir.mkdirs()) { - Timber.tag(TAG).w("Cannot create ${dir.path}") + is ApiResult.NetworkError -> { + Timber.tag(TAG).w("Cache lookup failed: ${result.exception.message}") return } + }.filter { cache -> + // The name becomes a path in the prefix; anything but a plain file name is refused. + val name = cache.fileName + val plain = name.isNotBlank() && name != "." && name != ".." && name.none { it == '/' || it == '\\' || it == '\u0000' } + if (!plain) Timber.tag(TAG).w("Ignoring cache with file name ${cache.fileName}") + plain + } + val dir = File(container.rootDir, CACHE_DIR) + if (!dir.isDirectory && !dir.mkdirs()) { + Timber.tag(TAG).w("Cannot create ${dir.path}") + return + } + val indexFile = File(container.rootDir, INSTALLED_INDEX) + val installed = runCatching { JSONObject(indexFile.readText()) }.getOrElse { JSONObject() } + val indexBefore = installed.toString() + try { // What the device holds, by sha256, so an older version can take a patch instead of a full file. // A file of another size with no patch on offer is replaced whole, so it is not hashed. val local = hosted.associateWith { cache -> val file = File(dir, cache.fileName) when { !file.isFile -> null - file.length() == cache.size || cache.patchFrom != null -> FexImages.sha256(file) + file.length() == cache.size || cache.patchFrom != null -> installedSha256(file, installed) else -> "" } } val missing = hosted.filter { cache -> local[cache] != cache.sha256 } Timber.tag(TAG).i("$fexVersion on $soc: ${images.size} images, ${hosted.size} hosted caches, ${missing.size} to download") - missing.forEachIndexed { index, cache -> - callbacks.setLoadingMessage("Downloading FEX code cache (${index + 1}/${missing.size})") - callbacks.setLoadingProgress(if (missing.size > 1) index.toFloat() / missing.size else -1f) - val have = local[cache]?.takeIf { it.isNotEmpty() } - runCatching { CodeCacheApi.download(cache, File(dir, cache.fileName), have) } - .onSuccess { renamed -> - if (renamed) { - Timber.tag(TAG).i("Updated ${cache.fileName} (${cache.size} B${if (have != null && have == cache.patchFrom) ", patched" else ""})") - } else { - Timber.tag(TAG).w("Could not move ${cache.fileName} into place") + if (missing.isNotEmpty()) downloadAll(missing, local, dir, installed, callbacks) + } finally { + saveIndex(indexFile, installed, indexBefore, dir) + } + } + + /** + * Downloads [missing] one after another, as one phase the player can skip and that ends after + * [DOWNLOAD_TIMEOUT_MS]. A skip or the time limit ends the phase without failing the launch. + */ + private suspend fun downloadAll( + missing: List, + local: Map, + dir: File, + installed: JSONObject, + callbacks: LaunchDependencyCallbacks, + ) { + coroutineScope { + val downloads = launch { + val finished = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { + missing.forEachIndexed { index, cache -> + callbacks.setLoadingMessage("Downloading FEX code cache (${index + 1}/${missing.size})") + callbacks.setLoadingProgress(index.toFloat() / missing.size) + val have = local[cache]?.takeIf { it.isNotEmpty() } + val target = File(dir, cache.fileName) + try { + val renamed = CodeCacheApi.download(cache, target, have) { fraction -> + callbacks.setLoadingProgress((index + fraction) / missing.size) + } + if (renamed) { + record(installed, target, cache.sha256) + Timber.tag(TAG).i("Updated ${cache.fileName} (${cache.size} B${if (have != null && have == cache.patchFrom) ", patched" else ""})") + } else { + Timber.tag(TAG).w("Could not move ${cache.fileName} into place") + } + } catch (e: Exception) { + // A cancelled call can surface as an IOException; a skip or timeout must still end the phase. + ensureActive() + Timber.tag(TAG).w(e, "Download of ${cache.fileName} failed") } } - .onFailure { Timber.tag(TAG).w(it, "Download of ${cache.fileName} failed") } + } + if (finished == null) Timber.tag(TAG).w("Downloads took over $DOWNLOAD_TIMEOUT_MS ms; launching with the caches in place") + } + LaunchSkip.offer { downloads.cancel() } + try { + downloads.join() + } finally { + LaunchSkip.withdraw() } + if (downloads.isCancelled) Timber.tag(TAG).i("Downloads skipped") } - stampFile(container).apply { - parentFile?.mkdirs() - writeText(fexVersion) + } + + /** The sha256 of an installed cache, from [installed] while the file's size and mtime match it. */ + private fun installedSha256(file: File, installed: JSONObject): String? { + val entry = installed.optJSONObject(file.name)?.takeIf { + it.optLong("size", -1) == file.length() && it.optLong("mtime", -1) == file.lastModified() && + it.optString("sha256").length == 64 } + if (entry != null) return entry.getString("sha256") + val sha256 = runCatching { FexImages.sha256(file) }.getOrElse { + Timber.tag(TAG).w(it, "Cannot hash ${file.path}") + return null + } + record(installed, file, sha256) + return sha256 + } + + private fun record(installed: JSONObject, file: File, sha256: String) { + installed.put(file.name, JSONObject().put("size", file.length()).put("mtime", file.lastModified()).put("sha256", sha256)) + } + + /** Writes [installed] when it changed, keeping the entries of caches that are still in [dir]. */ + private fun saveIndex(indexFile: File, installed: JSONObject, before: String, dir: File) { + runCatching { + installed.keys().asSequence().filter { !File(dir, it).isFile }.toList().forEach { installed.remove(it) } + if (installed.toString() != before) { + indexFile.parentFile?.mkdirs() + indexFile.writeText(installed.toString()) + } + }.onFailure { Timber.tag(TAG).w(it, "Cannot write ${indexFile.path}") } } /** @@ -190,6 +278,4 @@ object FexCodeCacheDependency : LaunchDependency { } return File(base, path.replace('\\', '/').trimStart('/')) } - - private fun stampFile(container: Container) = File(container.rootDir, ".gamenative/fex-cache-check-${container.fexCoreVersion}") } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 465b55c560..3628a3811b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2098,6 +2098,7 @@ Tip: Hvis du har et Mali GPU, skal du bruge systemdrivere. Tip: Får du en sort skærm? Prøv at bruge \"%s\"-indstillingen i menuen for at kontrollere, om dine drivere fungerer korrekt. Tip: Brug Proton x86-64, hvis du ikke kan klikke med musen i spil. + Spring over Aktiverer online-spil og forbedrer kompatibilitet\nVirker ikke altid\nTag backup af dine gemte spil, før du prøver dette diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 65f46bf1ef..5572a175a1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2168,6 +2168,7 @@ Tipp: Wenn Sie eine Mali-GPU haben, verwenden Sie bitte die Systemtreiber. Tipp: Zeigt sich ein schwarzer Bildschirm? Versuchen Sie die \"%s\"-Option im Menü, um zu überprüfen, ob Ihre Treiber korrekt funktionieren. Tipp: Verwenden Sie Proton x86-64, wenn Sie in Spielen nicht mit der Maus klicken können. + Überspringen Aktiviert Online-Spiel und verbessert die Kompatibilität\nFunktioniert nicht immer\nSichere deine Spielstände, bevor du es ausprobierst diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2a37ff59e8..d324046210 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2226,6 +2226,7 @@ Consejo: Si tienes una GPU Mali, usa los controladores del sistema. Consejo: ¿Pantalla negra? Prueba la opción \"%s\" en el menú para verificar si tus controladores funcionan correctamente. Consejo: Usa Proton x86-64 si no puedes hacer clic con el ratón en los juegos. + Omitir Habilita el juego en línea y mejora la compatibilidad\nNo siempre funciona\nHaz una copia de seguridad de tus partidas guardadas antes de probarlo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 31904141b9..ae3fe89f5f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2246,6 +2246,7 @@ Conseil : Si vous avez un GPU Mali, veuillez utiliser les pilotes système. Conseil : Écran noir ? Essayez l\'option \"%s\" dans le menu pour vérifier si vos pilotes fonctionnent correctement. Conseil : Utilisez Proton x86-64 si vous n\'arrivez pas à cliquer avec la souris dans les jeux. + Ignorer Active le jeu en ligne et améliore la compatibilité\nNe fonctionne pas toujours\nSauvegardez vos parties avant d’essayer diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16e9797934..2a3ee0b95f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2219,6 +2219,7 @@ Suggerimento: se hai una GPU Mali, utilizza i driver di sistema. Suggerimento: schermata nera? Prova l\'opzione \"%s\" nel menu per verificare se i tuoi driver funzionano correttamente. Suggerimento: usa Proton x86-64 se non riesci a cliccare con il mouse nei giochi. + Salta Abilita il gioco online e migliora la compatibilità\nNon sempre funziona\nEsegui un backup dei salvataggi prima di provarlo diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7c4aa949cb..ecb340f51a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -3072,6 +3072,7 @@ ヒント: Mali GPU をお持ちの場合は、システム ドライバーを使用してください。 ヒント: 空白の画面が表示されますか?メニューの \"%s\" オプションを使用して、ドライバーが正しく動作しているかどうかを確認してください。 ヒント: ゲームでマウスをクリックできない場合は、Proton x86-64 を使用してください。 + スキップ 端末から MOD をインポート アーカイブ、フォルダー、または 1 つ以上の個別ファイルをインポートします。ローカルインポートでも、Nexus ダウンロードと同じプロファイル、配置、競合、FOMOD、プラグイン管理を使用します。 アーカイブ: ZIP、7Z、RAR、配置可能な EXE。個別ファイルは INI、DLL、ESP、ESM、ESL、PAK、JSON、CFG、TOML など、任意の拡張子を使用できます。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1729f164c6..0584c6bb0c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2173,6 +2173,7 @@ 팁: Mali GPU를 사용하는 경우 시스템 드라이버를 사용하세요. 팁: 검은 화면이 나타나나요? 메뉴에서 \"%s\" 옵션을 사용하여 드라이버가 올바르게 작동하는지 확인하세요. 팁: 게임에서 마우스를 클릭할 수 없는 경우 Proton x86-64를 사용하세요. + 건너뛰기 로컬 저장 파일만 사용 이 게임의 클라우드 저장 동기화를 비활성화하고 저장 파일을 이 기기에만 보관합니다 실험적 Bionic Steam 활성화 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 36e234c322..334d2e4a74 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2256,6 +2256,7 @@ Wskazówka: Jeśli masz GPU Mali, użyj sterowników systemowych. Wskazówka: Czarny ekran? Wypróbuj opcję \"%s\" w menu, aby sprawdzić, czy sterowniki działają poprawnie. Wskazówka: Użyj Proton x86-64, jeśli nie możesz kliknąć myszą w grach. + Pomiń Włącza grę online, poprawia kompatybilność\nNie zawsze działa\nPrzed użyciem wykonaj kopię zapasową zapisów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0b381ac9de..d6254d43c8 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2098,6 +2098,7 @@ Dica: Se você tiver uma GPU Mali, use os drivers do sistema. Dica: Tela preta? Experimente a opção \"%s\" no menu para verificar se seus drivers estão funcionando corretamente. Dica: Use Proton x86-64 se você não conseguir clicar com o mouse nos jogos. + Pular Ativa o jogo online e melhora a compatibilidade\nNem sempre funciona\nFaça backup dos seus saves antes de testar diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 3e6655b078..60d9f39c3f 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2220,6 +2220,7 @@ Sfat: Dacă aveți un GPU Mali, vă rugăm să utilizați driverele de sistem. Sfat: Ecran negru? Încercați opțiunea \"%s\" din meniu pentru a verifica dacă driverele dvs. funcționează corect. Sfat: Utilizați Proton x86-64 dacă nu puteți face clic cu mouse-ul în jocuri. + Sari peste Activează jocul online și îmbunătățește compatibilitatea\nNu funcționează întotdeauna\nFă backup salvărilor înainte să încerci diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d73116769c..40ea2d46c0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2180,6 +2180,7 @@ https://gamenative.app Совет: Если у вас GPU Mali, используйте системные драйверы. Совет: Черный экран? Попробуйте использовать опцию \"%s\" в меню, чтобы проверить, правильно ли работают ваши драйверы. Совет: Используйте Proton x86-64, если вы не можете нажать мышью в играх. + Пропустить Включает онлайн-игру и улучшает совместимость\nРаботает не всегда\nСделайте резервную копию сохранений перед использованием diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5acf7c2cb3..14af71053c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2881,6 +2881,7 @@ İpucu: Mali GPU’nuz varsa lütfen Sistem Sürücülerini kullanın. İpucu: Boş ekran mı görüyorsunuz? Sürücülerin doğru çalışıp çalışmadığını kontrol etmek için menüdeki \"%s\" seçeneğini deneyin. İpucu: Oyunlarda fareye tıklayamıyorsanız Proton x86-64 kullanın. + Atla Cihazdan mod içe aktar Bir arşiv, klasör veya bir ya da daha fazla gevşek dosya içe aktarın. Yerel içe aktarmalar Nexus indirmeleriyle aynı profilleri, yerleşimi, çakışmaları, FOMOD’u ve eklenti yönetimini kullanır. Arşivler: ZIP, 7Z, RAR ve yerleştirilebilir EXE. Gevşek dosyalar INI, DLL, ESP, ESM, ESL, PAK, JSON, CFG ve TOML dahil herhangi bir uzantı kullanabilir. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1f0445854b..08e2c13bc3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2252,6 +2252,7 @@ Порада: Якщо у вас GPU Mali, будь ласка, використовуйте системні драйвери. Порада: Чорний екран? Спробуйте опцію \"%s\" у меню, щоб перевірити, чи ваші драйвери працюють правильно. Порада: Використовуйте Proton x86-64, якщо вам не вдається клацнути мишею в іграх. + Пропустити Увімкнення онлайн-гри та покращення сумісності\nПрацює не завжди\nПеред спробою зробіть резервну копію збережень diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 11c61ba14a..a79c713298 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2263,6 +2263,7 @@ 提示:如果你使用的是 Mali GPU,请使用系统驱动。 提示:出现黑屏?尝试使用菜单中的\"%s\"选项检查驱动是否正常工作。 提示:如果在游戏中无法点击鼠标,请使用 Proton x86-64。 + 跳过 启用在线游玩并提升兼容性\n不一定总能正常工作\n尝试前请先备份存档 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c94679b52a..a970697c32 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2254,6 +2254,7 @@ 提示:如果你使用的是 Mali GPU,請使用系統驅動。 提示:出現黑畫面?嘗試使用選單中的「%s」選項檢查驅動是否正常運作。 提示:如果你在遊戲中無法點擊滑鼠,請使用 Proton x86-64。 + 跳過 啟用線上遊玩並提升相容性\n不一定總能正常運作\n嘗試前請先備份存檔 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7732d78f4a..21cfa8b90d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3098,6 +3098,7 @@ Tip: If you have a Mali GPU, please use System Drivers. Tip: Getting a blank screen? Try using the \"%s\" option in the menu to check if your drivers are working correctly. Tip: Use Proton x86-64 if you can\'t click the mouse in games. + Skip Import mod from device Import an archive, a folder, or one or more loose files. Local imports use the same profiles, placement, conflicts, FOMOD, and plugin management as Nexus downloads. Archives: ZIP, 7Z, RAR, and placeable EXE. Loose files may use any extension, including INI, DLL, ESP, ESM, ESL, PAK, JSON, CFG, and TOML. From d0cf89b3acc1d931b18dd5b30eee62bc0b5a8125 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 09:20:34 +0530 Subject: [PATCH 02/10] Keep the progress callback last in downloadSigned so trailing-lambda callers compile --- app/src/main/java/app/gamenative/api/SupportApi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/api/SupportApi.kt b/app/src/main/java/app/gamenative/api/SupportApi.kt index 74a109d42a..9494be675a 100644 --- a/app/src/main/java/app/gamenative/api/SupportApi.kt +++ b/app/src/main/java/app/gamenative/api/SupportApi.kt @@ -912,8 +912,8 @@ object SupportApi { url: String, destination: File, maxBytes: Long = Long.MAX_VALUE, - onProgress: ((Float) -> Unit)? = null, client: OkHttpClient = transferClient, + onProgress: ((Float) -> Unit)? = null, ): ApiResult = withContext(Dispatchers.IO) { if (!url.startsWith("https://")) return@withContext ApiResult.HttpError(400, "bad_url") try { From ccd16e436174398a48f2380dcb5562b8d4ba5f97 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 09:52:09 +0530 Subject: [PATCH 03/10] Shader pools are per game and checked at app start; FEX caches are compiled on the device Shader cache: the pool key is the game only. At app start one background request checks which installed games have a newer pool; nothing is downloaded then. A launch downloads the pool only when it changed, with the existing limits and Skip, and makes no request when the check found nothing new. FEX: after a session the device compiles code caches from its own code maps with the pack's offline compiler, in the background, limited to 10 minutes per run and 512 MB per container, and stopped when a launch starts. The hosted cache lookup and download are removed. The code map upload is unchanged. --- app/src/main/java/app/gamenative/PluviaApp.kt | 11 + .../main/java/app/gamenative/PrefManager.kt | 2 +- .../java/app/gamenative/api/CodeCacheApi.kt | 140 ---- .../java/app/gamenative/api/ShaderCacheApi.kt | 38 +- .../shadercache/FossilizeManager.kt | 128 +++- .../gamenative/shadercache/ShaderCacheKey.kt | 35 +- .../ui/screen/xserver/XServerScreen.kt | 3 + .../app/gamenative/utils/FexCodeCaches.kt | 614 ++++++++++++++++++ .../java/app/gamenative/utils/FexCodeMaps.kt | 12 +- .../java/app/gamenative/utils/FexImages.kt | 194 ------ .../gamenative/utils/LaunchDependencies.kt | 6 +- .../FexCodeCacheDependency.kt | 281 -------- .../BionicProgramLauncherComponent.java | 4 + app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../shadercache/ShaderCacheKeyTest.kt | 15 + .../app/gamenative/utils/FexImagesTest.kt | 214 ------ 31 files changed, 849 insertions(+), 880 deletions(-) delete mode 100644 app/src/main/java/app/gamenative/api/CodeCacheApi.kt create mode 100644 app/src/main/java/app/gamenative/utils/FexCodeCaches.kt delete mode 100644 app/src/main/java/app/gamenative/utils/FexImages.kt delete mode 100644 app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt delete mode 100644 app/src/test/java/app/gamenative/utils/FexImagesTest.kt diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index f10b45a0a2..b4b05a5897 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.navigation.NavController import app.gamenative.db.dao.AmazonGameDao +import app.gamenative.db.dao.AppInfoDao +import app.gamenative.db.dao.EpicGameDao import app.gamenative.db.dao.GOGGameDao import app.gamenative.display.GamescopeCommandQueue import app.gamenative.events.EventDispatcher @@ -18,6 +20,7 @@ import app.gamenative.powercontrol.PowerManager import app.gamenative.service.ActiveGameRegistry import app.gamenative.service.DownloadService import app.gamenative.service.SteamService +import app.gamenative.shadercache.FossilizeManager import app.gamenative.sync.FrontendSyncManager import app.gamenative.ui.screen.xserver.RadialMenuCoordinator import app.gamenative.utils.AppExitRecorder @@ -60,6 +63,8 @@ class PluviaApp : SplitCompatApplication() { @Inject lateinit var gogGameDao: GOGGameDao @Inject lateinit var amazonGameDao: AmazonGameDao + @Inject lateinit var appInfoDao: AppInfoDao + @Inject lateinit var epicGameDao: EpicGameDao private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -131,6 +136,12 @@ class PluviaApp : SplitCompatApplication() { } } + if (PrefManager.sharedShaderCache && Build.FINGERPRINT != "robolectric") { + appScope.launch { + FossilizeManager.checkPoolsAtStart(applicationContext, appInfoDao, gogGameDao, epicGameDao, amazonGameDao) + } + } + // Clear any stale temporary config overrides from previous app sessions try { IntentLaunchManager.clearAllTemporaryOverrides() diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index e3442c33db..e9577eec20 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1968,7 +1968,7 @@ object PrefManager { get() = getPref(USAGE_ANALYTICS_ENABLED, true) set(value) { setPref(USAGE_ANALYTICS_ENABLED, value) } - // FEX code caches: share which code FEX translated and download prebuilt translations (FexCodeMaps). + // FEX code caches: share which code FEX translated and compile it into caches on the device (FexCodeMaps, FexCodeCaches). private val FEX_CODE_CACHE_ENABLED = booleanPreferencesKey("fex_code_cache_enabled") var fexCodeCacheEnabled: Boolean get() = getPref(FEX_CODE_CACHE_ENABLED, true) diff --git a/app/src/main/java/app/gamenative/api/CodeCacheApi.kt b/app/src/main/java/app/gamenative/api/CodeCacheApi.kt deleted file mode 100644 index 15d6b48eab..0000000000 --- a/app/src/main/java/app/gamenative/api/CodeCacheApi.kt +++ /dev/null @@ -1,140 +0,0 @@ -package app.gamenative.api - -import app.gamenative.utils.FexImages -import app.gamenative.utils.Net -import app.gamenative.utils.executeCancellable -import com.github.luben.zstd.ZstdInputStream -import okhttp3.Request -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.security.MessageDigest -import java.util.zip.GZIPInputStream - -/** Looks up and downloads hosted FEX code caches by image (POST /api/code-caches/lookup). */ -object CodeCacheApi { - - data class HostedCache( - val imageSha256: String, - /** The code identity the cache was matched on (FexImages.codeSha256). */ - val codeSha256: String, - /** The name FEX looks for on this device, computed by the server from the image's name and PE header. */ - val fileName: String, - val size: Long, - val compressedSize: Long, - val sha256: String, - val url: String, - /** sha256 of the previous version a hosted zstd patch applies to, if any. */ - val patchFrom: String?, - ) - - /** The hosted caches for [images] on this FEX build and SoC; images without one are left out. */ - suspend fun lookup(fexVersion: String, soc: String, images: List): ApiResult> { - val body = JSONObject().apply { - put("fexVersion", fexVersion) - put("soc", soc) - put( - "images", - JSONArray().apply { - images.forEach { image -> - put( - JSONObject() - .put("codeSha256", image.codeSha256) - .put("sha256", image.sha256) - .put("name", image.name) - .put("timeDateStamp", image.timeDateStamp) - .put("sizeOfImage", image.sizeOfImage), - ) - } - }, - ) - } - val request = GameNativeApi.buildPostRequest("${GameNativeApi.BASE_URL}/api/code-caches/lookup", body) - return GameNativeApi.executeRequest(request) { response -> - // An unrouted path on the API host answers 200 from another worker; only a real list counts. - val caches = JSONObject(response).optJSONArray("caches") ?: throw IOException("no cache list in response") - buildList { - for (i in 0 until caches.length()) { - val c = caches.getJSONObject(i) - add( - HostedCache( - c.getString("imageSha256"), - c.optString("codeSha256"), - c.getString("fileName"), - c.getLong("size"), - c.optLong("compressedSize", -1), - c.getString("sha256"), - c.getString("url"), - c.optString("patchFrom").takeIf { it.isNotBlank() && it != "null" }, - ), - ) - } - } - } - } - - /** - * Brings [target] to the hosted version. When the device holds the version a hosted patch - * applies to ([haveSha256] equals the cache's patchFrom), only the zstd patch is fetched and - * applied with the old file as dictionary; otherwise the full gzip is streamed. Either way the - * result is inflated through a temp file, hashed, and renamed only when size and sha256 match. - * Cancelling the coroutine stops the transfer at once; [onProgress] gets the inflated fraction (0..1). - */ - suspend fun download( - cache: HostedCache, - target: File, - haveSha256: String? = null, - onProgress: ((Float) -> Unit)? = null, - ): Boolean { - val tmp = File(target.parentFile, "${target.name}.part") - try { - val usePatch = haveSha256 != null && haveSha256 == cache.patchFrom && target.isFile - val url = if (usePatch) "${cache.url}?have=$haveSha256" else cache.url - val request = Request.Builder().url(url).get().build() - Net.launchDownload.newCall(request).executeCancellable { response -> - if (!response.isSuccessful) throw IOException("HTTP ${response.code}") - val isPatch = response.header("X-Patch-From") == haveSha256 && usePatch - val body = response.body!!.byteStream() - val input = if (isPatch) { - // zstd --patch-from: the previous file is the dictionary, window up to 2 GB. - ZstdInputStream(body).apply { - setLongMax(31) - setDict(target.readBytes()) - } - } else { - GZIPInputStream(body) - } - val digest = MessageDigest.getInstance("SHA-256") - var total = 0L - var reportedPercent = -1 - input.use { inflated -> - FileOutputStream(tmp).use { output -> - val buffer = ByteArray(256 * 1024) - while (true) { - val n = inflated.read(buffer) - if (n < 0) break - digest.update(buffer, 0, n) - output.write(buffer, 0, n) - total += n - if (onProgress != null && cache.size > 0) { - val percent = (total * 100 / cache.size).toInt().coerceIn(0, 100) - if (percent != reportedPercent) { - reportedPercent = percent - onProgress(percent / 100f) - } - } - } - } - } - val sha256 = digest.digest().joinToString("") { "%02x".format(it) } - if (total != cache.size || sha256 != cache.sha256) throw IOException("cache ${cache.fileName} did not verify") - } - return tmp.renameTo(target) - } catch (e: Exception) { - tmp.delete() - throw e - } - } -} diff --git a/app/src/main/java/app/gamenative/api/ShaderCacheApi.kt b/app/src/main/java/app/gamenative/api/ShaderCacheApi.kt index 6d799d3b5c..20c7c3de23 100644 --- a/app/src/main/java/app/gamenative/api/ShaderCacheApi.kt +++ b/app/src/main/java/app/gamenative/api/ShaderCacheApi.kt @@ -15,6 +15,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.json.JSONArray import org.json.JSONObject import timber.log.Timber @@ -22,6 +23,8 @@ object ShaderCacheApi { data class Pool(val version: Long, val url: String, val sha256: String, val size: Long, val entries: Long) + data class PoolVersion(val version: Long, val size: Long, val entries: Long) + data class UploadSlot(val key: String, val putUrl: String) data class UploadMeta( @@ -31,8 +34,13 @@ object ShaderCacheApi { val gpu: String, val driver: String, val displayServer: String, + val dxvk: String?, + val vkd3d: String?, ) + /** The most games one [checkPools] request may ask about. */ + const val MAX_CHECK_GAMES = 500 + private const val TAG = "ShaderCacheApi" private const val BASE_URL = "https://api.gamenative.app/v1/shadercache" @@ -41,8 +49,6 @@ object ShaderCacheApi { suspend fun fetchPool(key: ShaderCacheKey): ApiResult = call("pool") { val url = "$BASE_URL/pool".toHttpUrl().newBuilder() .addQueryParameter("game", key.game) - .addQueryParameter("dxvk", key.dxvk) - .addQueryParameter("vkd3d", key.vkd3d) .build() it.url(url).get() }.map { raw -> @@ -60,17 +66,41 @@ object ShaderCacheApi { ) } + /** + * The current pool version of each of [games] (at most [MAX_CHECK_GAMES]) that has a pool; games without one are + * absent from the result. + */ + suspend fun checkPools(games: List): ApiResult> = call("pools/check") { + it.url("$BASE_URL/pools/check").post(jsonBody(JSONObject().put("games", JSONArray(games)))) + }.map { raw -> + // Another service on this host can answer an unknown path with a 200 of its own. + val pools = JSONObject(raw.body).optJSONObject("pools") ?: throw IOException("pools/check: no pools in the response") + buildMap { + for (game in pools.keys()) { + val pool = pools.optJSONObject(game) ?: continue + put( + game, + PoolVersion( + version = pool.getLong("version"), + size = pool.optLong("size", -1L), + entries = pool.optLong("entries", 0L), + ), + ) + } + } + } + suspend fun beginUpload(key: ShaderCacheKey, meta: UploadMeta): ApiResult { val body = JSONObject() .put("game", key.game) - .put("dxvk", key.dxvk) - .put("vkd3d", key.vkd3d) .put("sha256", meta.sha256) .put("size", meta.size) .put("appVersion", meta.appVersion) .put("gpu", meta.gpu) .put("driver", meta.driver) .put("displayServer", meta.displayServer) + meta.dxvk?.let { body.put("dxvk", it) } + meta.vkd3d?.let { body.put("vkd3d", it) } return call("upload/begin") { it.url("$BASE_URL/upload/begin").post(jsonBody(body)) }.map { raw -> val json = JSONObject(raw.body) UploadSlot(key = json.getString("key"), putUrl = json.getString("putUrl")) diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index 2f6db9e61a..35480178fa 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -8,11 +8,16 @@ import app.gamenative.R import app.gamenative.api.ApiResult import app.gamenative.api.ShaderCacheApi import app.gamenative.api.SupportApi +import app.gamenative.db.dao.AmazonGameDao +import app.gamenative.db.dao.AppInfoDao +import app.gamenative.db.dao.EpicGameDao +import app.gamenative.db.dao.GOGGameDao import app.gamenative.events.AndroidEvent import app.gamenative.utils.LaunchSkip import app.gamenative.utils.Net import com.posthog.PostHog import com.winlator.container.Container +import com.winlator.container.ContainerManager import com.winlator.core.FileUtils import com.winlator.core.GPUInformation import com.winlator.core.KeyValueSet @@ -22,14 +27,17 @@ import com.winlator.xenvironment.components.BionicProgramLauncherComponent import java.io.File import java.io.IOException import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -42,11 +50,12 @@ import timber.log.Timber /** * Shared shader cache built on Valve's Fossilize. * + * App start: one request asks which installed games have a newer pool; nothing is downloaded. * Launch: the capture layer is installed per container as an implicit layer and records every * pipeline the game creates into a fresh session database, skipping entries already in the * downloaded pool. Exit: new session databases are uploaded and deleted. Before the next launch: - * the merged pool for the game is downloaded and replayed with fossilize-replay so the driver's - * disk cache is warm. + * the merged pool for the game is downloaded if it is newer and replayed with fossilize-replay so + * the driver's disk cache is warm. */ object FossilizeManager { private const val TAG = "Fossilize" @@ -72,6 +81,7 @@ object FossilizeManager { private const val FOZ_HEADER_BYTES = 16L private const val MAX_RETAINED_BYTES = 200L * 1024 * 1024 private const val MAX_TRANSFER_BYTES = 256L * 1024 * 1024 + private const val START_CHECK_DELAY_MS = 10_000L private const val POOL_LOOKUP_TIMEOUT_MS = 3_000L private const val POOL_DOWNLOAD_TIMEOUT_MS = 3 * 60 * 1000L private const val REPLAY_WALL_CLOCK_MS = 10 * 60 * 1000L @@ -87,11 +97,19 @@ object FossilizeManager { private val launchWorkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val uploadMutex = Mutex() + // What the server last said about each game this process asked about. A game in checkedGames but not in + // remotePools has no pool. + private val remotePools = ConcurrentHashMap() + private val checkedGames: MutableSet = ConcurrentHashMap.newKeySet() + private val startCheckRan = AtomicBoolean(false) + @Volatile private var activeSessionDir: File? = null private data class ReplayOutcome(val exitCode: Int, val timedOut: Boolean, val completed: Int, val total: Int, val tail: List) + private data class RecordedSession(val key: ShaderCacheKey, val dxvk: String?, val vkd3d: String?) + @JvmStatic fun isEnabled(container: Container?): Boolean = container != null && @@ -176,7 +194,7 @@ object FossilizeManager { sessionDir.mkdirs() activeSessionDir = sessionDir val key = ShaderCacheKey.of(container) - if (key != null) FileUtils.writeString(File(sessionDir, SESSION_KEY_FILENAME), key.toJson().toString()) + if (key != null) FileUtils.writeString(File(sessionDir, SESSION_KEY_FILENAME), sessionJson(key, container).toString()) envVars.remove("DISABLE_FOSSILIZE") envVars.put("FOSSILIZE", "1") envVars.put("FOSSILIZE_DUMP_PATH", File(sessionDir, "fossilize").absolutePath) @@ -261,11 +279,19 @@ object FossilizeManager { skipped: AtomicBoolean, ): JSONObject? { val metaFile = poolMetaFile(container, key) + dropStalePoolFiles(container, key) val local = runCatching { JSONObject(metaFile.readText()) }.getOrNull() ?.takeIf { poolFile(container, key).isFile } + if (key.game in checkedGames) { + val known = remotePools[key.game] ?: return local + if (local != null && local.optLong("version") >= known.version) return local + if (known.size > MAX_TRANSFER_BYTES) return local + } // Abandoned rather than cancelled: the lookup can include an account token refresh that has to finish. val remote = awaitWork(POOL_LOOKUP_TIMEOUT_MS, shouldStop, cancelOnStop = false) { ShaderCacheApi.fetchPool(key) } - val pool = if (remote is ApiResult.Success) remote.data ?: return local else return local + if (remote !is ApiResult.Success) return local + rememberPool(key.game, remote.data) + val pool = remote.data ?: return local if (local != null && local.optLong("version") >= pool.version) return local if (pool.size > MAX_TRANSFER_BYTES) { Timber.tag(TAG).w("Pool for %s is %d bytes, over the %d byte limit", key, pool.size, MAX_TRANSFER_BYTES) @@ -305,6 +331,77 @@ object FossilizeManager { return meta } + private fun rememberPool(game: String, pool: ShaderCacheApi.Pool?) { + if (pool == null) { + remotePools.remove(game) + } else { + remotePools[game] = ShaderCacheApi.PoolVersion(pool.version, pool.size, pool.entries) + } + checkedGames.add(game) + } + + /** Removes pool files under any other name, such as those of an earlier key format. */ + private fun dropStalePoolFiles(container: Container, key: ShaderCacheKey) { + val keep = setOf(poolFile(container, key).name, poolMetaFile(container, key).name, LOCAL_POOL_FILENAME) + poolDir(container).listFiles()?.forEach { if (it.isFile && it.name !in keep) it.delete() } + } + + /** + * Asks the server once per process which of the installed games have a pool and at which version, so a launch + * whose local pool is current makes no request. Downloads nothing; never throws. + */ + suspend fun checkPoolsAtStart( + context: Context, + appInfoDao: AppInfoDao, + gogGameDao: GOGGameDao, + epicGameDao: EpicGameDao, + amazonGameDao: AmazonGameDao, + ) { + if (!PrefManager.sharedShaderCache || !startCheckRan.compareAndSet(false, true)) return + try { + delay(START_CHECK_DELAY_MS) + val games = installedGames(context, appInfoDao, gogGameDao, epicGameDao, amazonGameDao) + for (chunk in games.chunked(ShaderCacheApi.MAX_CHECK_GAMES)) { + val result = ShaderCacheApi.checkPools(chunk) + if (result !is ApiResult.Success) { + Timber.tag(TAG).i("Pool check failed (%s)", result) + return + } + for ((game, pool) in result.data) { + remotePools.merge(game, pool) { old, new -> if (new.version >= old.version) new else old } + } + checkedGames.addAll(chunk) + } + Timber.tag(TAG).i("Checked %d games, %d have a pool", games.size, games.count { remotePools.containsKey(it) }) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Pool check failed") + } + } + + private suspend fun installedGames( + context: Context, + appInfoDao: AppInfoDao, + gogGameDao: GOGGameDao, + epicGameDao: EpicGameDao, + amazonGameDao: AmazonGameDao, + ): List { + val games = linkedSetOf() + suspend fun add(store: String, ids: suspend () -> List) { + runCatching { ids() }.getOrNull()?.forEach { id -> ShaderCacheKey.storeGame(store, id)?.let(games::add) } + } + add("steam") { appInfoDao.getAll().filter { it.isDownloaded }.map { it.id.toString() } } + add("gog") { gogGameDao.getInstalledGames().map { it.id } } + add("epic") { epicGameDao.getInstalledGames().map { it.appName } } + add("amazon") { amazonGameDao.getInstalledGames().map { ShaderCacheKey.amazonGameId(it.productId) } } + // Custom games are keyed by an exe hash, so only those with a shader cache container are asked about. + runCatching { ContainerManager(context).containers }.getOrNull() + ?.filter { ShaderCacheKey.isCustomGame(it) && isEnabled(it) } + ?.forEach { container -> runCatching { ShaderCacheKey.of(container) }.getOrNull()?.let { games.add(it.game) } } + return games.toList() + } + /** * Runs [block] off the calling thread and waits for it for at most [timeoutMs], or until [shouldStop]. Null if the * wait ended without a result or [block] failed; never throws. When the wait ends early, [block] is cancelled and @@ -509,12 +606,13 @@ object FossilizeManager { upload@ for (session in sessions) { val files = session.walkTopDown().filter { it.isFile && it.name.endsWith(".foz") }.sortedBy { it.lastModified() }.toList() if (files.isEmpty()) continue - val key = readSessionKey(session) - if (key == null) { + val recorded = readSession(session) + if (recorded == null) { Timber.tag(TAG).i("Dropping %s: no shader cache key was recorded with it", session.name) session.deleteRecursively() continue } + val key = recorded.key for (file in files) { val size = file.length() if (size <= FOZ_HEADER_BYTES || size > MAX_TRANSFER_BYTES) { @@ -530,6 +628,8 @@ object FossilizeManager { gpu = runCatching { GPUInformation.getRenderer(context) }.getOrDefault(""), driver = driverIdentity(container), displayServer = container.displayServer.orEmpty(), + dxvk = recorded.dxvk, + vkd3d = recorded.vkd3d, ) when (val slot = ShaderCacheApi.beginUpload(key, meta)) { is ApiResult.Success -> { @@ -567,15 +667,21 @@ object FossilizeManager { } } - private fun ShaderCacheKey.toJson(): JSONObject = JSONObject().put("game", game).put("dxvk", dxvk).put("vkd3d", vkd3d) + private fun sessionJson(key: ShaderCacheKey, container: Container): JSONObject { + val (dxvk, vkd3d) = ShaderCacheKey.layerVersions(container) + return JSONObject().put("game", key.game).put("dxvk", dxvk).put("vkd3d", vkd3d) + } - private fun readSessionKey(session: File): ShaderCacheKey? = runCatching { + private fun readSession(session: File): RecordedSession? = runCatching { val json = JSONObject(File(session, SESSION_KEY_FILENAME).readText()) - ShaderCacheKey(json.getString("game"), json.getString("dxvk"), json.getString("vkd3d")) + RecordedSession( + key = ShaderCacheKey(json.getString("game")), + dxvk = json.optString("dxvk").ifEmpty { null }, + vkd3d = json.optString("vkd3d").ifEmpty { null }, + ) }.getOrNull() - private fun keyProperties(key: ShaderCacheKey): Map = - mapOf("game" to key.game, "dxvk" to key.dxvk, "vkd3d" to key.vkd3d) + private fun keyProperties(key: ShaderCacheKey): Map = mapOf("game" to key.game) private fun sha256(file: File): String { val digest = MessageDigest.getInstance("SHA-256") diff --git a/app/src/main/java/app/gamenative/shadercache/ShaderCacheKey.kt b/app/src/main/java/app/gamenative/shadercache/ShaderCacheKey.kt index b22e2e79c3..95e679b168 100644 --- a/app/src/main/java/app/gamenative/shadercache/ShaderCacheKey.kt +++ b/app/src/main/java/app/gamenative/shadercache/ShaderCacheKey.kt @@ -10,24 +10,28 @@ import java.security.MessageDigest import java.util.Locale /** - * Identifies a shared shader pool: the game plus the translation layers whose SPIR-V it records. + * Identifies a shared shader pool: the game. Fossilize entries are content-addressed, so one pool + * per game holds the pipelines of every DXVK and vkd3d version. * * game: steam:, gog:, epic:, amazon:, resolved from the container id * (Epic and Amazon container ids are local database ids); custom games use * custom:. + * + * The translation layer versions are sent with an upload only to describe it: * dxvk: dxvk- from dxwrapperConfig "version": the leading version number after an optional * "dxvk-" / "v" (2.7.1-1-gplasync-0 -> dxvk-2.7.1); anything else is kept lowercased. * vkd3d: vkd3d- from dxwrapperConfig "vkd3dVersion": the leading version number and any * letter suffix after an optional "vkd3d-proton-" / "vkd3d-" / "v" (vkd3d-proton-2.14.1 -> * vkd3d-2.14.1, 3.0b -> vkd3d-3.0b). */ -data class ShaderCacheKey(val game: String, val dxvk: String, val vkd3d: String) { +data class ShaderCacheKey(val game: String) { val fileName: String - get() = "$game-$dxvk-$vkd3d".replace(UNSAFE_FILE_CHARS, "_") + get() = game.replace(UNSAFE_FILE_CHARS, "_") companion object { private const val CUSTOM_HASH_BYTES = 1 shl 20 + private const val CUSTOM_GAME_PREFIX = "CUSTOM_GAME_" private val UNSAFE_FILE_CHARS = Regex("[^A-Za-z0-9._-]") private val DXVK_VERSION = Regex("^(?:dxvk-?)?v?(\\d+\\.\\d+(?:\\.\\d+)?)") private val VKD3D_VERSION = Regex("^(?:vkd3d-?)?(?:proton-?)?v?(\\d+\\.\\d+(?:\\.\\d+)?[a-z]*)") @@ -39,12 +43,20 @@ data class ShaderCacheKey(val game: String, val dxvk: String, val vkd3d: String) "AMAZON_" to "amazon", ) - fun of(container: Container): ShaderCacheKey? { - val game = gameKey(container) ?: return null + fun of(container: Container): ShaderCacheKey? = gameKey(container)?.let(::ShaderCacheKey) + + /** The dxvk and vkd3d versions [container] runs, as described above. */ + fun layerVersions(container: Container): Pair { val config = KeyValueSet(container.dxWrapperConfig) - return ShaderCacheKey(game, dxvkKey(config.get("version")), vkd3dKey(config.get("vkd3dVersion"))) + return dxvkKey(config.get("version")) to vkd3dKey(config.get("vkd3dVersion")) } + /** The game key for a store game id that is already the stable one (for Amazon, see [amazonGameId]). */ + fun storeGame(store: String, gameId: String): String? = + if (STORE_GAME_ID.matches(gameId)) "$store:$gameId" else null + + fun amazonGameId(productId: String): String = productId.substringAfterLast('.') + fun dxvkKey(version: String?): String = "dxvk-" + normalize(version, DXVK_VERSION) fun vkd3dKey(version: String?): String = "vkd3d-" + normalize(version, VKD3D_VERSION) @@ -54,24 +66,25 @@ data class ShaderCacheKey(val game: String, val dxvk: String, val vkd3d: String) return pattern.find(clean)?.groupValues?.get(1) ?: clean.ifEmpty { "none" } } + fun isCustomGame(container: Container): Boolean = container.id.startsWith(CUSTOM_GAME_PREFIX) + private fun gameKey(container: Container): String? { - val id = container.id.substringBefore('(') - if (id.startsWith("CUSTOM_GAME_")) return customGameKey(container) - return storeGameKey(id) + if (isCustomGame(container)) return customGameKey(container) + return storeGameKey(container.id.substringBefore('(')) } internal fun storeGameKey(id: String, stableId: (String, String) -> String? = ::stableStoreId): String? { for ((prefix, store) in STORE_PREFIXES) { if (!id.startsWith(prefix)) continue val gameId = stableId(store, id.removePrefix(prefix)) ?: return null - return if (STORE_GAME_ID.matches(gameId)) "$store:$gameId" else null + return storeGame(store, gameId) } return null } private fun stableStoreId(store: String, localId: String): String? = when (store) { "epic" -> localId.toIntOrNull()?.let { EpicService.getEpicGameOf(it) }?.appName - "amazon" -> localId.toIntOrNull()?.let { AmazonService.getAmazonGameByAppId(it) }?.productId?.substringAfterLast('.') + "amazon" -> localId.toIntOrNull()?.let { AmazonService.getAmazonGameByAppId(it) }?.productId?.let(::amazonGameId) else -> localId } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 4b23af5f53..72e20770c1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -5550,6 +5550,9 @@ private fun exit( (frameRating?.context ?: PluviaApp.xServerView?.context)?.applicationContext?.let { appContext -> val sessionLengthSec = frameRating?.sessionLengthSec?.toInt() CoroutineScope(Dispatchers.IO).launch { + // Keeps this session's maps for the on-device compile before the upload deletes them; the compile runs on its own. + runCatching { app.gamenative.utils.FexCodeCaches.afterSession(appContext, container) } + .onFailure { Timber.w(it, "Code cache compile could not start") } runCatching { FexCodeMaps.uploadAfterSession(appContext, container, appId, sessionLengthSec) } .onFailure { Timber.w(it, "Code map upload failed") } } diff --git a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt new file mode 100644 index 0000000000..9e9c3284f7 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt @@ -0,0 +1,614 @@ +package app.gamenative.utils + +import android.content.Context +import android.os.SystemClock +import android.system.Os +import app.gamenative.PluviaApp +import app.gamenative.PrefManager +import com.winlator.container.Container +import com.winlator.contents.ContentsManager +import com.winlator.core.ProcessHelper +import com.winlator.core.envvars.EnvVars +import com.winlator.fexcore.FEXCoreManager +import com.winlator.fexcore.FEXCorePresetManager +import com.winlator.xenvironment.components.BionicProgramLauncherComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest +import java.util.Locale + +/** + * FEX code caches compiled on the device from its own code maps (FexCodeMaps). After a session the + * maps FEX wrote are hard-linked into an inbox before the upload deletes them, then a background job + * merges them into one cumulative map per image (a session that loaded a cache records only the + * misses, so the union is what the cache must cover) and runs the pack's FEXOfflineCompiler under the + * session's own launcher environment, through `wine cmd /c` on a batch file in the prefix. Each image is + * recompiled only when its cumulative map grew; a new FEX build or FEX setting deletes every cache, + * because FEX does not check that a cache matches the build that loads it. + */ +object FexCodeCaches { + + private const val TAG = "FexCodeCache" + private const val FEX_DIR = ".wine/drive_c/users/xuser/AppData/Local/fex-emu" + private const val WIN_WORK_DIR = "C:\\users\\xuser\\AppData\\Local\\fex-emu\\gamenative" + private const val STATE_FILE = ".gamenative/fex-codecache.json" + private val HOSTED_LEFTOVERS = listOf(".gamenative/fex-cache-installed.json", ".gamenative/fex-image-hashes.json") + + // FEXOfflineCompiler names caches -- and always writes config id 0. + private const val CACHE_SUFFIX = "-0000000000000000" + + // About 1,250 blocks a second on a Snapdragon 8 Gen 2, so this covers maps far larger than the + // biggest seen (327k blocks for one exe). + private const val COMPILE_TIMEOUT_MS = 10 * 60 * 1000L + private const val STOP_WAIT_MS = 5_000L + private const val WINE_EXIT_WAIT_MS = 15_000L + private const val POLL_MS = 250L + + // One large game's exe is about 240 MB of cache; this keeps it plus its own DLLs and the Steam + // client's. Every cache can be rebuilt from the maps, so eviction only costs compile time. + private const val STORAGE_CAP_BYTES = 512L * 1024 * 1024 + private const val MAX_INBOX_FILES = 20 + private const val MAX_MAP_BYTES = 8L * 1024 * 1024 + private const val STALE_IMAGE_MS = 60L * 24 * 60 * 60 * 1000 + private const val LOG_TAIL_LINES = 12 + + // FEX options that change generated code, by the name of their environment variable. + private val CODEGEN_OPTIONS = listOf( + "Multiblock", "MaxInst", "HostFeatures", "SmallTSCScale", "HideHybrid", "SoftwareRNG", "CPUFeatureRegisters", + "DisableL2Cache", "DynamicL1Cache", "SMCChecks", "TSOEnabled", "VectorTSOEnabled", "MemcpySetTSOEnabled", + "HalfBarrierTSOEnabled", "StrictInProcessSplitLocks", "KernelUnalignedAtomicBackpatching", "VolatileMetadata", + "ExtendedVolatileMetadata", "X87ReducedPrecision", "HideHypervisorBit", "MonoHacks", "ForceSVEWidth", + ).associateBy { "FEX_" + it.uppercase(Locale.ROOT) } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val lock = Any() + private var job: Job? = null + + @Volatile + private var stopReason: String? = null + + @Volatile + private var sessionLauncher: BionicProgramLauncherComponent? = null + + private fun fexDir(container: Container) = File(container.rootDir, FEX_DIR) + private fun cacheDir(container: Container) = File(fexDir(container), "cache") + private fun workDir(container: Container) = File(fexDir(container), "gamenative") + private fun inboxDir(container: Container) = File(workDir(container), "inbox") + private fun mapsDir(container: Container) = File(workDir(container), "maps") + private fun stagingDir(container: Container) = File(workDir(container), "staging") + private fun logsDir(container: Container) = File(workDir(container), "logs") + + /** + * Called by the launcher right before a guest starts: stops a running compile, remembers the + * launcher for the compile after this session, and deletes caches built for another FEX build or + * other FEX settings so this launch never loads them. Never throws. + */ + @JvmStatic + fun onGuestStarting(context: Context, contentsManager: ContentsManager, launcher: BionicProgramLauncherComponent) { + try { + stopForLaunch("a game is starting") + sessionLauncher = launcher + val container = launcher.container ?: return + if (!FexCodeMaps.captureEnabled(context, contentsManager, container)) return + val state = readState(container) + if (invalidateIfBuildChanged(container, state, buildId(context, container, launcher))) writeState(container, state) + val caches = cacheDir(container).listFiles { f -> f.isFile }.orEmpty() + if (caches.isEmpty()) { + Timber.tag(TAG).i("Launch with no code caches for ${container.id}") + } else { + Timber.tag(TAG).i( + "Launch with ${caches.size} code caches (${mb(caches.sumOf { it.length() })}) for ${container.id}: " + + caches.joinToString { "${it.name} (${it.length()} B)" }, + ) + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Code cache check before launch failed") + } + } + + /** Stops a running compile and waits for it for at most [STOP_WAIT_MS]. Never throws. */ + @JvmStatic + fun stopForLaunch(reason: String) { + try { + val running = synchronized(lock) { job?.takeIf { it.isActive } } ?: return + stopReason = reason + Timber.tag(TAG).i("Stopping the code cache compile: $reason") + val ended = runBlocking { withTimeoutOrNull(STOP_WAIT_MS) { running.join() } } + if (ended == null) Timber.tag(TAG).w("Code cache compile still running $STOP_WAIT_MS ms after the stop") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Could not stop the code cache compile") + } + } + + /** + * Called once the session's environment is down. Links this session's maps into the inbox (the + * upload deletes the originals) and starts the background compile; returns at once. + */ + fun afterSession(context: Context, container: Container) { + val launcher = sessionLauncher?.takeIf { it.container?.id == container.id } + sessionLauncher = null + try { + if (!FexCodeMaps.captureEnabled(context, container)) { + Timber.tag(TAG).i("Skip compile for ${container.id}: FEX code caches are off or the FEX pack has no offline compiler") + return + } + linkSessionMaps(container) + synchronized(lock) { + if (job?.isActive == true) { + Timber.tag(TAG).i("Skip compile for ${container.id}: another compile is running; the maps wait in the inbox") + return + } + stopReason = null + job = scope.launch { + try { + compileAfterSession(context, container, launcher) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Code cache compile for ${container.id} failed") + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Code cache step after the session failed") + } + } + + private fun linkSessionMaps(container: Container) { + val maps = FexCodeMaps.codeMapDir(container).listFiles { f -> f.isFile && f.name.endsWith(".bin") }.orEmpty() + if (maps.isEmpty()) { + Timber.tag(TAG).i("No code maps from this session") + return + } + val inbox = inboxDir(container).apply { mkdirs() } + var linked = 0 + for (map in maps) { + val target = File(inbox, map.name) + if (target.exists()) continue + try { + try { + Os.link(map.path, target.path) + } catch (e: Exception) { + map.copyTo(target, overwrite = true) + } + linked++ + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Could not keep code map ${map.name}") + } + } + inbox.listFiles { f -> f.isFile }.orEmpty().sortedByDescending { it.lastModified() }.drop(MAX_INBOX_FILES).forEach { + Timber.tag(TAG).w("Dropping code map ${it.name} from the inbox: more than $MAX_INBOX_FILES waiting") + it.delete() + } + Timber.tag(TAG).i("Kept $linked of ${maps.size} code maps from this session for the compile") + } + + private suspend fun compileAfterSession(context: Context, container: Container, launcher: BionicProgramLauncherComponent?) { + val state = readState(container) + mergeInbox(container, state) + pruneStaleImages(container, state) + writeState(container, state) + if (launcher == null) return skip(container, "no launcher from this session") + compile(context, container, launcher, state) + } + + private fun skip(container: Container, reason: String) { + Timber.tag(TAG).i("Skip compile for ${container.id}: $reason") + } + + private class Offsets { + var values = IntArray(1024) + var size = 0 + + fun add(value: Int) { + if (size == values.size) values = values.copyOf(size * 2) + values[size++] = value + } + + fun sortedDistinct(): IntArray { + val sorted = values.copyOf(size).also { it.sort() } + if (sorted.isEmpty()) return sorted + var n = 1 + for (i in 1 until sorted.size) if (sorted[i] != sorted[n - 1]) sorted[n++] = sorted[i] + return sorted.copyOf(n) + } + } + + private class SessionImage(val record: ByteArray, val path: String, val bits: Int) { + val offsets = Offsets() + } + + /** + * Folds every inbox map into the cumulative map of each image it touched, adding the blocks the + * upload ledger holds for those images (maps uploaded before this existed were deleted after + * upload). Builtin Wine images and processes FEX runs with their own app config are left out. + */ + private fun mergeInbox(container: Container, state: JSONObject) { + val files = inboxDir(container).listFiles { f -> f.isFile && f.name.endsWith(".bin") }.orEmpty().sortedBy { it.name } + if (files.isEmpty()) return + val images = HashMap() + var builtins = 0 + for (file in files) { + try { + val info = if (file.length() <= MAX_MAP_BYTES) FexCodeMaps.readInfo(file) else null + if (info == null) { + Timber.tag(TAG).w("Ignoring unreadable code map ${file.name} (${file.length()} B)") + continue + } + if (FEXCoreManager.findAppConfigExeName(info.mainExe) != null) { + Timber.tag(TAG).i("Ignoring code map ${file.name}: ${info.mainExe} runs with its own FEX config") + continue + } + val libraries = HashMap() + val blocks = HashMap() + FexCodeMaps.forEachRecord(file.readBytes()) { fileId, blockOffset, raw -> + when { + fileId != FexCodeMaps.RESERVED_FILE_ID -> blocks.getOrPut(fileId) { Offsets() }.add(blockOffset) + blockOffset == FexCodeMaps.LOAD_EXTERNAL_LIBRARY -> { + libraries[ByteBuffer.wrap(raw, FexCodeMaps.ENTRY_BYTES, 8).order(ByteOrder.LITTLE_ENDIAN).long] = raw + } + } + } + for ((fileId, record) in libraries) { + val offsets = blocks[fileId] ?: continue + val path = recordPath(record) + if (isWineBuiltin(path)) { + builtins++ + continue + } + val image = images.getOrPut(fileId) { SessionImage(record, path, info.bitness) } + for (i in 0 until offsets.size) image.offsets.add(offsets.values[i]) + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Could not read code map ${file.name}") + } + } + addSentBlocks(container, images) + + val entries = imagesOf(state) + val now = System.currentTimeMillis() + mapsDir(container).mkdirs() + for ((fileId, image) in images) { + val key = fileIdHex(fileId) + val mapFile = File(mapsDir(container), "$key.bin") + val before = readMapBlocks(mapFile, fileId) + val merged = Offsets() + before.forEach { merged.add(it) } + for (i in 0 until image.offsets.size) merged.add(image.offsets.values[i]) + val all = merged.sortedDistinct() + val entry = entries.optJSONObject(key) ?: JSONObject().also { entries.put(key, it) } + entry.put("path", image.path).put("name", imageName(image.path)).put("bits", image.bits).put("lastSeen", now) + if (all.size > before.size || !mapFile.isFile) { + writeMap(mapFile, fileId, image.record, all) + Timber.tag(TAG).i("Merged ${imageName(image.path)} ($key, ${image.bits}-bit): +${all.size - before.size} new blocks, ${all.size} total") + } + entry.put("blocks", all.size) + } + files.forEach { it.delete() } + Timber.tag(TAG).i("Merged ${files.size} code maps: ${images.size} images, $builtins Wine builtin images left out") + } + + private fun addSentBlocks(container: Container, images: Map) { + val sent = FexCodeMaps.sentBlocksFile(container) + if (images.isEmpty() || !sent.isFile) return + runCatching { + val buf = ByteBuffer.wrap(sent.readBytes()).order(ByteOrder.LITTLE_ENDIAN) + while (buf.remaining() >= FexCodeMaps.ENTRY_BYTES) { + val fileId = buf.long + val blockOffset = buf.int + images[fileId]?.offsets?.add(blockOffset) + } + }.onFailure { Timber.tag(TAG).w(it, "Could not read the uploaded-block ledger") } + } + + /** The path in a library-load record: after the 12-byte entry and the 8-byte file id, up to the NUL. */ + private fun recordPath(record: ByteArray): String { + val start = FexCodeMaps.ENTRY_BYTES + 8 + var end = start + while (end < record.size && record[end] != 0.toByte()) end++ + return String(record, start, end - start, Charsets.UTF_8) + } + + // Wine substitutes its own builtins for these at load time, so a cache compiled from the file is invalid. + private fun isWineBuiltin(path: String): Boolean = path.lowercase(Locale.ROOT).contains(":\\windows\\") + + private fun imageName(path: String): String = path.substringAfterLast('\\').substringAfterLast('/') + + private fun fileIdHex(fileId: Long) = "%016x".format(fileId) + + private fun readMapBlocks(file: File, fileId: Long): IntArray { + if (!file.isFile) return IntArray(0) + val offsets = Offsets() + FexCodeMaps.forEachRecord(file.readBytes()) { id, blockOffset, _ -> if (id == fileId) offsets.add(blockOffset) } + return offsets.sortedDistinct() + } + + /** A map in the layout FEXOfflineCompiler writes for one library: its load record, then its blocks. */ + private fun writeMap(file: File, fileId: Long, record: ByteArray, offsets: IntArray) { + val buf = ByteBuffer.allocate(record.size + offsets.size * FexCodeMaps.ENTRY_BYTES).order(ByteOrder.LITTLE_ENDIAN) + buf.put(record) + offsets.forEach { buf.putLong(fileId).putInt(it) } + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeBytes(buf.array()) + if (!tmp.renameTo(file)) { + tmp.delete() + throw IllegalStateException("cannot move ${tmp.path} into place") + } + } + + private fun pruneStaleImages(container: Container, state: JSONObject) { + val entries = imagesOf(state) + val cutoff = System.currentTimeMillis() - STALE_IMAGE_MS + for (key in entries.keys().asSequence().toList()) { + val entry = entries.getJSONObject(key) + if (entry.optLong("lastSeen") >= cutoff) continue + File(mapsDir(container), "$key.bin").delete() + entry.optString("cacheName").takeIf { it.isNotEmpty() }?.let { File(cacheDir(container), it).delete() } + entries.remove(key) + Timber.tag(TAG).i("Dropped ${entry.optString("name")} ($key): not loaded for 60 days") + } + } + + private suspend fun compile(context: Context, container: Container, launcher: BionicProgramLauncherComponent, state: JSONObject) { + stopReason?.let { return skip(container, it) } + if (!PrefManager.fexCodeCacheEnabled) return skip(container, "FEX code caches were turned off") + if (PluviaApp.xEnvironment != null) return skip(container, "a game session is active") + if (!waitForWineToExit(container)) return + stopReason?.let { return skip(container, it) } + + val (build, config) = buildOf(context, container, launcher) + invalidateIfBuildChanged(container, state, build) + HOSTED_LEFTOVERS.forEach { File(container.rootDir, it).delete() } + + val system32 = File(container.rootDir, ".wine/drive_c/windows/system32") + val images = selectImages(container, state).filter { (key, entry) -> + val bits = entry.optInt("bits") + val ok = File(system32, "FEXOfflineCompiler$bits.exe").isFile + if (!ok) Timber.tag(TAG).i("Skip ${entry.optString("name")} ($key): no FEXOfflineCompiler$bits.exe in system32") + ok + } + writeState(container, state) + if (images.isEmpty()) return skip(container, "nothing new to compile") + + val work = workDir(container) + val staging = stagingDir(container) + val logs = logsDir(container) + listOf(staging, logs).forEach { dir -> + dir.mkdirs() + dir.listFiles().orEmpty().forEach { it.delete() } + } + File(work, "Config.json").writeText( + JSONObject().put( + "Config", + JSONObject().apply { + config.forEach { (name, value) -> put(name, value) } + put("EnableCodeCachingWIP", "1") + put("SilentLog", "0") + }, + ).toString(), + ) + val lines = mutableListOf("@echo off") + for ((key, entry) in images) { + val compiler = "C:\\windows\\system32\\FEXOfflineCompiler${entry.optInt("bits")}.exe" + lines += "$compiler generate --fileid $key --outdir $WIN_WORK_DIR\\staging/ $WIN_WORK_DIR\\maps\\$key.bin" + + " > $WIN_WORK_DIR\\logs\\$key.out.txt 2> $WIN_WORK_DIR\\logs\\$key.err.txt" + lines += "echo %ERRORLEVEL% > $WIN_WORK_DIR\\logs\\$key.exit.txt" + } + File(work, "compile.bat").writeText(lines.joinToString("\r\n", postfix = "\r\n")) + + Timber.tag(TAG).i( + "Compile started for ${container.id}, FEX ${container.fexCoreVersion}, config $config: ${images.size} images: " + + images.joinToString { (key, entry) -> "${entry.optString("name")} ($key, ${entry.optInt("blocks")} blocks, ${entry.optInt("bits")}-bit)" }, + ) + val started = SystemClock.elapsedRealtime() + val env = EnvVars().apply { + // The real prefix path, not the home symlink, which a launch of another container repoints. + put("WINEPREFIX", File(container.rootDir, ".wine").absolutePath) + put("FEX_APP_CONFIG_LOCATION", "$WIN_WORK_DIR\\") + put("WINEDEBUG", "+err") + } + val unset = arrayOf("FEX_ENABLECODECACHINGWIP") + val run = scope.async { launcher.execShellCommand("wine cmd /c $WIN_WORK_DIR\\compile.bat", true, env, unset) } + var ended: String? = null + while (run.isActive) { + stopReason?.let { ended = "stopped: $it" } + if (ended == null && SystemClock.elapsedRealtime() - started > COMPILE_TIMEOUT_MS) ended = "hit the ${COMPILE_TIMEOUT_MS / 60_000}-minute limit" + if (ended != null) break + withTimeoutOrNull(POLL_MS) { run.join() } + } + ended?.let { reason -> + Timber.tag(TAG).w("Compile for ${container.id} $reason; killing its Wine processes") + runCatching { launcher.execShellCommand("wineserver -k", true, env, unset) } + .onFailure { Timber.tag(TAG).w(it, "wineserver -k after the compile failed") } + withTimeoutOrNull(STOP_WAIT_MS) { run.join() } + } + if (run.isCompleted && !run.isCancelled) { + val output = runCatching { run.await() }.getOrDefault("") + if (output.isNotBlank()) Timber.tag(TAG).i("Wine output:\n${tail(output)}") + } + + val cacheDir = cacheDir(container).apply { mkdirs() } + var produced = 0 + var producedBytes = 0L + var failed = 0 + for ((key, entry) in images) { + val name = entry.optString("name") + val blocks = entry.optInt("blocks") + val output = staging.listFiles { f -> f.isFile && f.name.endsWith("-$key$CACHE_SUFFIX") }?.firstOrNull() + val exit = File(logs, "$key.exit.txt").takeIf { it.isFile }?.readText()?.trim() + if (output != null && output.length() > 0 && (exit == null || exit == "0")) { + // The staging directory is the temp name: the compiler wrote and renamed the file there, and + // this rename puts the finished file where FEX looks in one step. + val target = File(cacheDir, output.name) + if (output.renameTo(target)) { + entry.put("builtBlocks", blocks).put("cacheName", target.name).put("cacheSize", target.length()) + entry.remove("failedBlocks") + entry.remove("evictedAtBlocks") + produced++ + producedBytes += target.length() + Timber.tag(TAG).i("Cache ${target.name}: ${target.length()} B from $blocks blocks") + } else { + Timber.tag(TAG).w("Could not move ${output.name} into ${cacheDir.path}") + } + } else if (exit != null) { + failed++ + entry.put("failedBlocks", blocks) + Timber.tag(TAG).w( + "Compile of $name ($key) failed (exit $exit):\n" + + tail(listOf("out", "err").joinToString("\n") { File(logs, "$key.$it.txt").takeIf { f -> f.isFile }?.readText().orEmpty() }), + ) + } else { + Timber.tag(TAG).i("Not compiled: $name ($key); the run ended before it") + } + } + staging.listFiles().orEmpty().forEach { it.delete() } + val total = enforceCap(container, state) + writeState(container, state) + Timber.tag(TAG).i( + "Compile finished for ${container.id} in %.1f s%s: %d caches written (%s), %d failed; container caches now %s" + .format(Locale.ROOT, (SystemClock.elapsedRealtime() - started) / 1000.0, ended?.let { " ($it)" }.orEmpty(), produced, mb(producedBytes), failed, mb(total)), + ) + } + + private suspend fun waitForWineToExit(container: Container): Boolean { + val deadline = SystemClock.elapsedRealtime() + WINE_EXIT_WAIT_MS + while (true) { + val running = ProcessHelper.listRunningWineProcesses() + if (running.isEmpty()) return true + if (stopReason != null || SystemClock.elapsedRealtime() > deadline) { + skip(container, "Wine processes still running: ${running.joinToString()}") + return false + } + delay(500) + } + } + + /** Images whose cumulative map has blocks no cache covers yet, most blocks first. */ + private fun selectImages(container: Container, state: JSONObject): List> { + val entries = imagesOf(state) + val selected = mutableListOf>() + var upToDate = 0 + for (key in entries.keys()) { + val entry = entries.getJSONObject(key) + val name = entry.optString("name") + val blocks = entry.optInt("blocks") + val cacheName = entry.optString("cacheName") + if (blocks == 0 || !File(mapsDir(container), "$key.bin").isFile) continue + if (entry.optInt("builtBlocks", -1) == blocks && cacheName.isNotEmpty() && File(cacheDir(container), cacheName).isFile) { + upToDate++ + continue + } + if (entry.optInt("failedBlocks", -1) == blocks) { + Timber.tag(TAG).i("Skip $name ($key): its compile failed at $blocks blocks and the map has not grown") + continue + } + val evictedAt = entry.optInt("evictedAtBlocks") + if (evictedAt > 0 && blocks < evictedAt * 2) { + Timber.tag(TAG).i("Skip $name ($key): evicted for the storage cap at $evictedAt blocks; waiting for ${evictedAt * 2}") + continue + } + selected += key to entry + } + if (upToDate > 0) Timber.tag(TAG).i("$upToDate images already have a cache for every recorded block") + return selected.sortedByDescending { it.second.optInt("blocks") } + } + + /** Deletes caches until the container's total is under [STORAGE_CAP_BYTES]; returns the total left. */ + private fun enforceCap(container: Container, state: JSONObject): Long { + val files = cacheDir(container).listFiles { f -> f.isFile }.orEmpty() + var total = files.sumOf { it.length() } + if (total <= STORAGE_CAP_BYTES) return total + val entries = imagesOf(state) + val byCache = entries.keys().asSequence().map { entries.getJSONObject(it) }.associateBy { it.optString("cacheName") } + // Least valuable first: files this does not track, then images not loaded for longest, then fewest blocks. + val order = files.sortedWith( + compareBy( + { if (byCache.containsKey(it.name)) 1 else 0 }, + { byCache[it.name]?.optLong("lastSeen") ?: 0L }, + { byCache[it.name]?.optInt("builtBlocks") ?: 0 }, + ), + ) + for (file in order) { + if (total <= STORAGE_CAP_BYTES) break + val size = file.length() + if (!file.delete()) continue + total -= size + byCache[file.name]?.let { entry -> + entry.put("evictedAtBlocks", entry.optInt("builtBlocks")) + entry.remove("builtBlocks") + entry.remove("cacheName") + entry.remove("cacheSize") + } + Timber.tag(TAG).i("Evicted ${file.name} ($size B): caches over the ${mb(STORAGE_CAP_BYTES)} cap") + } + return total + } + + private fun buildOf(context: Context, container: Container, launcher: BionicProgramLauncherComponent): Pair> { + val config = sortedMapOf() + val env = FEXCorePresetManager.getEnvVars(context, launcher.fexCorePreset) + launcher.envVars?.let { env.putAll(it) } + for (name in env) CODEGEN_OPTIONS[name.uppercase(Locale.ROOT)]?.let { config[it] = env.get(name) } + val digest = MessageDigest.getInstance("SHA-256").digest(config.entries.joinToString(";").toByteArray()) + return "${container.fexCoreVersion}/${digest.take(8).joinToString("") { "%02x".format(it) }}" to config + } + + private fun buildId(context: Context, container: Container, launcher: BionicProgramLauncherComponent) = + buildOf(context, container, launcher).first + + /** + * Deletes every cache in the prefix when [build] differs from the one the caches were compiled + * for, including caches from before this state existed. The maps stay: they do not depend on the + * FEX build. Returns whether [state] changed. + */ + private fun invalidateIfBuildChanged(container: Container, state: JSONObject, build: String): Boolean { + val old = state.optString("build") + if (old == build) return false + val caches = cacheDir(container).listFiles { f -> f.isFile }.orEmpty() + val bytes = caches.sumOf { it.length() } + val deleted = caches.count { it.delete() } + val entries = imagesOf(state) + entries.keys().forEach { key -> + entries.getJSONObject(key).apply { + remove("builtBlocks") + remove("cacheName") + remove("cacheSize") + remove("failedBlocks") + remove("evictedAtBlocks") + } + } + state.put("build", build) + Timber.tag(TAG).i("FEX build or settings changed (${old.ifEmpty { "none" }} -> $build): deleted $deleted caches (${mb(bytes)})") + return true + } + + private fun imagesOf(state: JSONObject): JSONObject = + state.optJSONObject("images") ?: JSONObject().also { state.put("images", it) } + + private fun readState(container: Container): JSONObject = + runCatching { JSONObject(File(container.rootDir, STATE_FILE).readText()) }.getOrElse { JSONObject() } + + private fun writeState(container: Container, state: JSONObject) { + runCatching { + val file = File(container.rootDir, STATE_FILE) + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(state.toString()) + if (!tmp.renameTo(file)) tmp.delete() + }.onFailure { Timber.tag(TAG).w(it, "Could not save the code cache state") } + } + + private fun tail(text: String): String = text.trim().lines().takeLast(LOG_TAIL_LINES).joinToString("\n") + + private fun mb(bytes: Long): String = "%.1f MB".format(Locale.ROOT, bytes / (1024.0 * 1024.0)) +} diff --git a/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt b/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt index 500eeb9c05..e31ab4cff6 100644 --- a/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt +++ b/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt @@ -32,13 +32,13 @@ object FexCodeMaps { private const val TAG = "FexCodeMaps" private const val CODEMAP_DIR = ".wine/drive_c/users/xuser/AppData/Local/fex-emu/codemap/new" - private const val ENTRY_BYTES = 12 + internal const val ENTRY_BYTES = 12 private const val MAX_MAP_BYTES = 8L * 1024 * 1024 private const val MAX_FILES_PER_SESSION = 10 // Reserved file id that introduces a record instead of a block, and the record kinds. - private const val RESERVED_FILE_ID = -1L - private const val LOAD_EXTERNAL_LIBRARY = -1 + internal const val RESERVED_FILE_ID = -1L + internal const val LOAD_EXTERNAL_LIBRARY = -1 private const val SET_EXECUTABLE_32 = -18 private const val SET_EXECUTABLE_64 = -17 @@ -156,7 +156,7 @@ object FexCodeMaps { continue } // Only blocks this device has never sent go up; FEX re-records cached code as nothing, so - // with a hosted cache in place a session's map is already just the misses. + // with a cache in place a session's map is already just the misses. delta = File.createTempFile("codemap-", ".delta", context.cacheDir) val newBlocks = writeDelta(file, delta, sent) if (newBlocks == 0) { @@ -209,7 +209,7 @@ object FexCodeMaps { } /** Every (file id, block offset) this container has uploaded, as 12-byte records like the maps themselves. */ - private fun sentBlocksFile(container: Container) = File(container.rootDir, ".gamenative/fex-codemap-sent.bin") + internal fun sentBlocksFile(container: Container) = File(container.rootDir, ".gamenative/fex-codemap-sent.bin") private fun readSentBlocks(container: Container): HashSet { val set = HashSet() @@ -288,7 +288,7 @@ object FexCodeMaps { } /** Walks a map and hands each entry or record to [visit] with its raw bytes. */ - private fun forEachRecord(bytes: ByteArray, visit: (fileId: Long, blockOffset: Int, raw: ByteArray) -> Unit) { + internal fun forEachRecord(bytes: ByteArray, visit: (fileId: Long, blockOffset: Int, raw: ByteArray) -> Unit) { val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) while (buf.remaining() >= ENTRY_BYTES) { val start = buf.position() diff --git a/app/src/main/java/app/gamenative/utils/FexImages.kt b/app/src/main/java/app/gamenative/utils/FexImages.kt deleted file mode 100644 index 810870a93e..0000000000 --- a/app/src/main/java/app/gamenative/utils/FexImages.kt +++ /dev/null @@ -1,194 +0,0 @@ -package app.gamenative.utils - -import org.json.JSONObject -import timber.log.Timber -import java.io.File -import java.io.RandomAccessFile -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest - -/** - * The PE images a hosted FEX code cache is looked up by. A cache is keyed by the image's code - * identity ([codeSha256]), which survives per-game restamping of resources and timestamps; FEX finds it on disk by a name derived from the lowercase file name and two PE header fields - * (TimeDateStamp, SizeOfImage), which the server turns into the file name for this device. - * Hashes are kept in a per-container index keyed by host path, size and mtime, so each file - * version is hashed once. - */ -object FexImages { - - private const val TAG = "FexImages" - private const val INDEX_FILE = ".gamenative/fex-image-hashes.json" - - data class Header(val timeDateStamp: Long, val sizeOfImage: Long) - - data class Image( - val sha256: String, - val codeSha256: String, - val name: String, - val timeDateStamp: Long, - val sizeOfImage: Long, - ) - - private val SKIPPED_SECTIONS = setOf(".rsrc", ".reloc") - - /** - * Reads TimeDateStamp (e_lfanew+8) and SizeOfImage (e_lfanew+24+56, the same offset in PE32 and - * PE32+) after checking the "MZ" and "PE\0\0" signatures. Null for anything that is not a PE image. - */ - fun readHeader(file: File): Header? = runCatching { - RandomAccessFile(file, "r").use { raf -> - val length = raf.length() - if (length < 0x40) return null - val dos = ByteArray(0x40) - raf.readFully(dos) - if (dos[0] != 'M'.code.toByte() || dos[1] != 'Z'.code.toByte()) return null - val peOffset = ByteBuffer.wrap(dos).order(ByteOrder.LITTLE_ENDIAN).getInt(0x3c).toLong() and 0xffffffffL - if (peOffset + 24 + 60 > length) return null - val nt = ByteArray(24 + 60) - raf.seek(peOffset) - raf.readFully(nt) - val buf = ByteBuffer.wrap(nt).order(ByteOrder.LITTLE_ENDIAN) - if (buf.getInt(0) != 0x00004550) return null - Header(buf.getInt(8).toLong() and 0xffffffffL, buf.getInt(24 + 56).toLong() and 0xffffffffL) - } - }.getOrNull() - - /** - * Describes each PE file in [files], hashing only those whose size or mtime differ from the - * index under [containerRoot]. Files that are not PE images or cannot be read are left out. - * The index keeps the entries of files that still exist. - */ - fun describe(containerRoot: File, files: List): List { - val indexFile = File(containerRoot, INDEX_FILE) - val index = runCatching { JSONObject(indexFile.readText()) }.getOrElse { JSONObject() } - var changed = false - val images = files.mapNotNull { file -> - val header = readHeader(file) ?: return@mapNotNull null - val key = file.absolutePath - val size = file.length() - val mtime = file.lastModified() - val cached = index.optJSONObject(key)?.takeIf { - it.optLong("size", -1) == size && it.optLong("mtime", -1) == mtime && - it.optLong("timeDateStamp", -1) == header.timeDateStamp && it.optLong("sizeOfImage", -1) == header.sizeOfImage && - it.optString("sha256").length == 64 && it.optString("codeSha256").length == 64 - } - val (sha256, codeSha256) = if (cached != null) { - cached.getString("sha256") to cached.getString("codeSha256") - } else { - val hashes = runCatching { - sha256(file) to (codeSha256(file) ?: throw IllegalArgumentException("malformed section table")) - }.getOrElse { - Timber.tag(TAG).w(it, "Cannot hash ${file.path}") - return@mapNotNull null - } - index.put( - key, - JSONObject() - .put("size", size) - .put("mtime", mtime) - .put("sha256", hashes.first) - .put("codeSha256", hashes.second) - .put("timeDateStamp", header.timeDateStamp) - .put("sizeOfImage", header.sizeOfImage), - ) - changed = true - hashes - } - Image(sha256, codeSha256, file.name.lowercase(), header.timeDateStamp, header.sizeOfImage) - } - val gone = index.keys().asSequence().filter { !File(it).isFile }.toList() - gone.forEach { index.remove(it) } - if (changed || gone.isNotEmpty()) { - runCatching { - indexFile.parentFile?.mkdirs() - indexFile.writeText(index.toString()) - }.onFailure { Timber.tag(TAG).w(it, "Cannot write ${indexFile.path}") } - } - return images - } - - /** - * SHA-256 over, in section-table order, every section other than .rsrc and .reloc: u32le - * VirtualAddress, VirtualSize and SizeOfRawData, then SizeOfRawData bytes at PointerToRawData, - * with any part past the end of the file counted as zeros. Null if the file is not a PE image - * or its section table does not fit in the file. - */ - fun codeSha256(file: File): String? = runCatching { - RandomAccessFile(file, "r").use { raf -> - val length = raf.length() - if (length < 0x40) return null - val dos = ByteArray(0x40) - raf.readFully(dos) - if (dos[0] != 'M'.code.toByte() || dos[1] != 'Z'.code.toByte()) return null - val peOffset = ByteBuffer.wrap(dos).order(ByteOrder.LITTLE_ENDIAN).getInt(0x3c).toLong() and 0xffffffffL - if (peOffset + 24 > length) return null - val coff = ByteArray(24) - raf.seek(peOffset) - raf.readFully(coff) - val coffBuf = ByteBuffer.wrap(coff).order(ByteOrder.LITTLE_ENDIAN) - if (coffBuf.getInt(0) != 0x00004550) return null - val sectionCount = coffBuf.getShort(6).toInt() and 0xffff - val optionalHeaderSize = coffBuf.getShort(20).toInt() and 0xffff - val tableOffset = peOffset + 24 + optionalHeaderSize - if (tableOffset + sectionCount * 40L > length) return null - val table = ByteArray(sectionCount * 40) - raf.seek(tableOffset) - raf.readFully(table) - val sections = ByteBuffer.wrap(table).order(ByteOrder.LITTLE_ENDIAN) - - val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(256 * 1024) - val fields = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) - for (i in 0 until sectionCount) { - val base = i * 40 - val nameEnd = (0 until 8).firstOrNull { table[base + it] == 0.toByte() } ?: 8 - val name = String(table, base, nameEnd, Charsets.US_ASCII) - if (name in SKIPPED_SECTIONS) continue - val virtualSize = sections.getInt(base + 8) - val virtualAddress = sections.getInt(base + 12) - val rawSize = sections.getInt(base + 16).toLong() and 0xffffffffL - val rawPointer = sections.getInt(base + 20).toLong() and 0xffffffffL - // A section cannot be larger than the file it sits in; refuse to hash gigabytes of - // padding for a header that says otherwise. - if (rawSize > length || rawPointer > length) return null - fields.clear() - fields.putInt(virtualAddress).putInt(virtualSize).putInt(rawSize.toInt()) - digest.update(fields.array()) - - var remaining = rawSize - var position = rawPointer - while (remaining > 0 && position < length) { - raf.seek(position) - val n = raf.read(buffer, 0, minOf(remaining, buffer.size.toLong()).toInt()) - if (n < 0) break - digest.update(buffer, 0, n) - remaining -= n - position += n - } - if (remaining > 0) { - buffer.fill(0) - while (remaining > 0) { - val n = minOf(remaining, buffer.size.toLong()).toInt() - digest.update(buffer, 0, n) - remaining -= n - } - } - } - digest.digest().joinToString("") { "%02x".format(it) } - } - }.getOrNull() - - fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - file.inputStream().use { input -> - val buffer = ByteArray(256 * 1024) - while (true) { - val n = input.read(buffer) - if (n < 0) break - digest.update(buffer, 0, n) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } - } -} diff --git a/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt b/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt index 5c9f7b0334..2bf367d983 100644 --- a/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt +++ b/app/src/main/java/app/gamenative/utils/LaunchDependencies.kt @@ -6,13 +6,14 @@ import app.gamenative.data.GameSource import app.gamenative.utils.launchdependencies.BionicDefaultProtonDependency import app.gamenative.utils.launchdependencies.EpicOverlayDependency import app.gamenative.utils.launchdependencies.EpicSdkRestoreDependency -import app.gamenative.utils.launchdependencies.FexCodeCacheDependency import app.gamenative.utils.launchdependencies.GogScriptInterpreterDependency import app.gamenative.utils.launchdependencies.LaunchDependencyCallbacks import app.gamenative.utils.launchdependencies.LaunchDependency import app.gamenative.utils.launchdependencies.BionicSteamAssetsDependency import app.gamenative.utils.launchdependencies.RockstarHelperDependency import com.winlator.container.Container +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext const val LOADING_PROGRESS_UNKNOWN: Float = -1f @@ -30,7 +31,6 @@ class LaunchDependencies { EpicSdkRestoreDependency, BionicSteamAssetsDependency, RockstarHelperDependency, - FexCodeCacheDependency, ) private var dependenciesProvider: () -> List = { launchDependencies } @@ -47,6 +47,8 @@ class LaunchDependencies { setLoadingMessage: (String) -> Unit, setLoadingProgress: (Float) -> Unit, ) { + // An on-device code cache compile from the last session must not compete with this launch. + withContext(Dispatchers.IO) { FexCodeCaches.stopForLaunch("a launch was requested") } val callbacks = LaunchDependencyCallbacks(setLoadingMessage, setLoadingProgress) try { for (dep in getLaunchDependencies(container, gameSource, gameId)) { diff --git a/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt b/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt deleted file mode 100644 index 052028fb36..0000000000 --- a/app/src/main/java/app/gamenative/utils/launchdependencies/FexCodeCacheDependency.kt +++ /dev/null @@ -1,281 +0,0 @@ -package app.gamenative.utils.launchdependencies - -import android.content.Context -import app.gamenative.PrefManager -import app.gamenative.api.ApiResult -import app.gamenative.api.CodeCacheApi -import app.gamenative.data.GameSource -import app.gamenative.service.SteamService -import app.gamenative.utils.ContainerUtils -import app.gamenative.utils.FexCodeMaps -import app.gamenative.utils.FexImages -import app.gamenative.utils.HardwareUtils -import app.gamenative.utils.LaunchSkip -import com.winlator.container.Container -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject -import timber.log.Timber -import java.io.File - -/** - * Puts the hosted FEX code caches for the images this game loads into the prefix's fex-emu/cache/ - * before launch, so FEX loads translated code instead of compiling it. Caches are hosted per image - * code identity (FexImages.codeSha256), FEX build and SoC; the server names each one the way FEX looks for it on this device - * (image name plus file id from the PE header), so a stale file after a game update is ignored. - * The lookup runs on every launch but holds it for at most [LOOKUP_TIMEOUT_MS], and downloads, which - * the player can skip, for at most [DOWNLOAD_TIMEOUT_MS]; failures never block the launch. - */ -object FexCodeCacheDependency : LaunchDependency { - - private const val TAG = "FexCodeCache" - private const val CACHE_DIR = ".wine/drive_c/users/xuser/AppData/Local/fex-emu/cache" - private const val STEAM_DIR = ".wine/drive_c/Program Files (x86)/Steam" - private const val INSTALLED_INDEX = ".gamenative/fex-cache-installed.json" - private const val LOOKUP_TIMEOUT_MS = 3_000L - private const val DOWNLOAD_TIMEOUT_MS = 3 * 60 * 1000L - private const val MAX_IMAGES = 64 - private const val MAX_IMAGE_BYTES = 512L * 1024 * 1024 - private val STEAM_CLIENT_DLLS = listOf( - "steamclient64.dll", - "steamclient.dll", - "steam_api64.dll", - "steam_api.dll", - "gameoverlayrenderer64.dll", - "gameoverlayrenderer.dll", - ) - - // A lookup that outlives the launch's wait keeps running here: it may be refreshing the account - // token, and cancelling that mid-flight can lose the rotated refresh token. - private val lookupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - override fun appliesTo(container: Container, gameSource: GameSource, gameId: Int): Boolean = - PrefManager.fexCodeCacheEnabled && "fexcore".equals(container.emulator, ignoreCase = true) - - // A pack without the offline compiler cannot load caches; nothing to do for it. - override fun isSatisfied(context: Context, container: Container, gameSource: GameSource, gameId: Int): Boolean = - !FexCodeMaps.supportsCodeMaps(context, container) - - override fun getLoadingMessage(context: Context, container: Container, gameSource: GameSource, gameId: Int): String = - "Checking FEX code cache" - - override suspend fun install( - context: Context, - container: Container, - callbacks: LaunchDependencyCallbacks, - gameSource: GameSource, - gameId: Int, - ) = withContext(Dispatchers.IO) { - try { - refresh(container, callbacks, gameSource, gameId) - } catch (e: CancellationException) { - ensureActive() - Timber.tag(TAG).w(e, "Hosted cache check was cancelled") - } catch (e: Exception) { - Timber.tag(TAG).w(e, "Hosted cache check failed") - } - } - - private suspend fun refresh(container: Container, callbacks: LaunchDependencyCallbacks, gameSource: GameSource, gameId: Int) { - val soc = HardwareUtils.getSOCName() - if (soc.isNullOrBlank()) { - Timber.tag(TAG).i("No SoC name; skipping hosted caches") - return - } - val fexVersion = container.fexCoreVersion - val images = FexImages.describe(container.rootDir, imageFiles(container, gameSource, gameId)) - if (images.isEmpty()) { - Timber.tag(TAG).i("No images to look up") - return - } - val lookup = lookupScope.async { CodeCacheApi.lookup(fexVersion, soc, images) } - val result = withTimeoutOrNull(LOOKUP_TIMEOUT_MS) { lookup.await() } - if (result == null) { - Timber.tag(TAG).w("Cache lookup took over $LOOKUP_TIMEOUT_MS ms; launching with the local caches") - return - } - val hosted = when (result) { - is ApiResult.Success -> result.data - is ApiResult.HttpError -> { - Timber.tag(TAG).w("Cache lookup failed: HTTP ${result.code} ${result.message}") - return - } - is ApiResult.NetworkError -> { - Timber.tag(TAG).w("Cache lookup failed: ${result.exception.message}") - return - } - }.filter { cache -> - // The name becomes a path in the prefix; anything but a plain file name is refused. - val name = cache.fileName - val plain = name.isNotBlank() && name != "." && name != ".." && name.none { it == '/' || it == '\\' || it == '\u0000' } - if (!plain) Timber.tag(TAG).w("Ignoring cache with file name ${cache.fileName}") - plain - } - val dir = File(container.rootDir, CACHE_DIR) - if (!dir.isDirectory && !dir.mkdirs()) { - Timber.tag(TAG).w("Cannot create ${dir.path}") - return - } - val indexFile = File(container.rootDir, INSTALLED_INDEX) - val installed = runCatching { JSONObject(indexFile.readText()) }.getOrElse { JSONObject() } - val indexBefore = installed.toString() - try { - // What the device holds, by sha256, so an older version can take a patch instead of a full file. - // A file of another size with no patch on offer is replaced whole, so it is not hashed. - val local = hosted.associateWith { cache -> - val file = File(dir, cache.fileName) - when { - !file.isFile -> null - file.length() == cache.size || cache.patchFrom != null -> installedSha256(file, installed) - else -> "" - } - } - val missing = hosted.filter { cache -> local[cache] != cache.sha256 } - Timber.tag(TAG).i("$fexVersion on $soc: ${images.size} images, ${hosted.size} hosted caches, ${missing.size} to download") - if (missing.isNotEmpty()) downloadAll(missing, local, dir, installed, callbacks) - } finally { - saveIndex(indexFile, installed, indexBefore, dir) - } - } - - /** - * Downloads [missing] one after another, as one phase the player can skip and that ends after - * [DOWNLOAD_TIMEOUT_MS]. A skip or the time limit ends the phase without failing the launch. - */ - private suspend fun downloadAll( - missing: List, - local: Map, - dir: File, - installed: JSONObject, - callbacks: LaunchDependencyCallbacks, - ) { - coroutineScope { - val downloads = launch { - val finished = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { - missing.forEachIndexed { index, cache -> - callbacks.setLoadingMessage("Downloading FEX code cache (${index + 1}/${missing.size})") - callbacks.setLoadingProgress(index.toFloat() / missing.size) - val have = local[cache]?.takeIf { it.isNotEmpty() } - val target = File(dir, cache.fileName) - try { - val renamed = CodeCacheApi.download(cache, target, have) { fraction -> - callbacks.setLoadingProgress((index + fraction) / missing.size) - } - if (renamed) { - record(installed, target, cache.sha256) - Timber.tag(TAG).i("Updated ${cache.fileName} (${cache.size} B${if (have != null && have == cache.patchFrom) ", patched" else ""})") - } else { - Timber.tag(TAG).w("Could not move ${cache.fileName} into place") - } - } catch (e: Exception) { - // A cancelled call can surface as an IOException; a skip or timeout must still end the phase. - ensureActive() - Timber.tag(TAG).w(e, "Download of ${cache.fileName} failed") - } - } - } - if (finished == null) Timber.tag(TAG).w("Downloads took over $DOWNLOAD_TIMEOUT_MS ms; launching with the caches in place") - } - LaunchSkip.offer { downloads.cancel() } - try { - downloads.join() - } finally { - LaunchSkip.withdraw() - } - if (downloads.isCancelled) Timber.tag(TAG).i("Downloads skipped") - } - } - - /** The sha256 of an installed cache, from [installed] while the file's size and mtime match it. */ - private fun installedSha256(file: File, installed: JSONObject): String? { - val entry = installed.optJSONObject(file.name)?.takeIf { - it.optLong("size", -1) == file.length() && it.optLong("mtime", -1) == file.lastModified() && - it.optString("sha256").length == 64 - } - if (entry != null) return entry.getString("sha256") - val sha256 = runCatching { FexImages.sha256(file) }.getOrElse { - Timber.tag(TAG).w(it, "Cannot hash ${file.path}") - return null - } - record(installed, file, sha256) - return sha256 - } - - private fun record(installed: JSONObject, file: File, sha256: String) { - installed.put(file.name, JSONObject().put("size", file.length()).put("mtime", file.lastModified()).put("sha256", sha256)) - } - - /** Writes [installed] when it changed, keeping the entries of caches that are still in [dir]. */ - private fun saveIndex(indexFile: File, installed: JSONObject, before: String, dir: File) { - runCatching { - installed.keys().asSequence().filter { !File(dir, it).isFile }.toList().forEach { installed.remove(it) } - if (installed.toString() != before) { - indexFile.parentFile?.mkdirs() - indexFile.writeText(installed.toString()) - } - }.onFailure { Timber.tag(TAG).w(it, "Cannot write ${indexFile.path}") } - } - - /** - * The images to look up: the game's executable, every .dll next to it and, for Steam games, the - * Steam client DLLs in the prefix. At most [MAX_IMAGES], none over [MAX_IMAGE_BYTES]. - */ - private fun imageFiles(container: Container, gameSource: GameSource, gameId: Int): List { - val files = LinkedHashSet() - val exe = runCatching { executableFile(container, gameSource, gameId) } - .onFailure { Timber.tag(TAG).w(it, "Cannot resolve the executable") } - .getOrNull() - if (exe != null && exe.isFile) { - files.add(exe) - exe.parentFile?.listFiles { f -> f.isFile && f.name.endsWith(".dll", ignoreCase = true) } - ?.sortedBy { it.name.lowercase() } - ?.let { files.addAll(it) } - } else { - Timber.tag(TAG).i("Executable ${container.executablePath} not found on disk (${exe?.path})") - } - if (gameSource == GameSource.STEAM) { - val steamDir = File(container.rootDir, STEAM_DIR) - // The prefix keeps Steam's own casing (GameOverlayRenderer.dll) on a case-sensitive filesystem. - steamDir.listFiles { f -> f.isFile && f.name.lowercase() in STEAM_CLIENT_DLLS } - ?.sortedBy { it.name.lowercase() } - ?.let { files.addAll(it) } - } - return files.filter { it.length() <= MAX_IMAGE_BYTES }.take(MAX_IMAGES) - } - - /** - * The container's executable as a host path, resolved the way the launcher does: an absolute - * Windows path through the prefix's drive symlinks, a relative one against the Steam app - * directory for Steam games and the A: drive (the game folder) for every other store. - */ - private fun executableFile(container: Container, gameSource: GameSource, gameId: Int): File? { - val path = container.executablePath.ifEmpty { - if (gameSource == GameSource.STEAM) SteamService.getInstalledExe(gameId) else "" - } - if (path.isEmpty()) return null - if (ContainerUtils.isAbsoluteWindowsPath(path)) { - val letter = path[0].lowercaseChar() - val rest = path.substring(3).replace('\\', '/') - val drive = File(container.rootDir, ".wine/dosdevices/$letter:").takeIf { it.exists() } - ?: Container.drivesIterator(container.drives).firstOrNull { it[0].equals(letter.toString(), ignoreCase = true) }?.let { File(it[1]) } - ?: return null - return File(drive, rest) - } - // Epic may keep the executable as a host path inside its install directory. - if (path.startsWith("/") && File(path).isFile) return File(path) - val base = if (gameSource == GameSource.STEAM) { - SteamService.getAppDirPath(gameId) - } else { - ContainerUtils.getADrivePath(container.drives) ?: return null - } - return File(base, path.replace('\\', '/').trimStart('/')) - } -} diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 6a6e625d1b..076bd6fdc5 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -22,6 +22,7 @@ import app.gamenative.PluviaApp; import app.gamenative.display.GameNativeDrm; import app.gamenative.display.GamescopeSettings; +import app.gamenative.utils.FexCodeCaches; import app.gamenative.utils.FexCodeMaps; import app.gamenative.utils.LsfgQuickMenuHelper; import app.gamenative.shadercache.FossilizeManager; @@ -148,6 +149,7 @@ public BionicProgramLauncherComponent(ContentsManager contentsManager, ContentPr @Override public void start() { synchronized (lock) { + FexCodeCaches.onGuestStarting(environment.getContext(), contentsManager, this); stop(); if (wineInfo.isArm64EC()) extractEmulatorsDlls(); @@ -227,6 +229,8 @@ public void setBox64Preset(String box64Preset) { public void setFEXCorePreset (String fexcorePreset) { this.fexcorePreset = fexcorePreset; } + public String getFEXCorePreset() { return fexcorePreset; } + public File getWorkingDir() { return workingDir; } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3628a3811b..20beeff9bf 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1012,7 +1012,7 @@ Anvend kendt konfiguration automatisk Anvend automatisk anbefalede indstillinger for Steam-spil ved første start FEX-kodecacher - Del hvilken kode FEX oversatte, og hent færdige oversættelser for hurtigere start. Cacher bruger lagerplads i spillets præfiks. + Del hvilken kode FEX oversatte, og forbered oversættelserne på enheden efter du har spillet, for hurtigere start. Cacher bruger lagerplads i spillets præfiks. Box64-forudindstillinger Vis, redigér og opret Box64-forudindstillinger Driverhåndtering diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5572a175a1..0f7175f9db 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1156,7 +1156,7 @@ Bekannte Konfiguration automatisch anwenden Beim ersten Start automatisch empfohlene Einstellungen für Steam-Spiele anwenden FEX-Code-Caches - Teile, welchen Code FEX übersetzt hat, und lade vorgefertigte Übersetzungen für schnellere Starts. Caches belegen Speicherplatz im Präfix des Spiels. + Teile, welchen Code FEX übersetzt hat, und bereite diese Übersetzungen nach dem Spielen auf dem Gerät vor, für schnellere Starts. Caches belegen Speicherplatz im Präfix des Spiels. Box64-Voreinstellungen Voreinstellungen anzeigen, ändern oder neu erstellen Treiberverwaltung diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d324046210..b7ddf11b76 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1204,7 +1204,7 @@ Aplicar configuración conocida automáticamente Aplica automáticamente la configuración recomendada para juegos de Steam al iniciarlos por primera vez. Cachés de código FEX - Comparte qué código tradujo FEX y descarga traducciones precompiladas para arrancar más rápido. Las cachés ocupan espacio en el prefijo del juego. + Comparte qué código tradujo FEX y prepara esas traducciones en el dispositivo después de jugar para arrancar más rápido. Las cachés ocupan espacio en el prefijo del juego. Preajustes de Box64 Ve, modifica y crea preajustes de Box64. Gestor de controladores diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ae3fe89f5f..15a6f2ec7b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1214,7 +1214,7 @@ Appliquer automatiquement la configuration connue Appliquer automatiquement les paramètres recommandés pour les jeux Steam au premier lancement Caches de code FEX - Partage le code traduit par FEX et télécharge des traductions précompilées pour des lancements plus rapides. Les caches occupent de l\'espace dans le préfixe du jeu. + Partage le code traduit par FEX et prépare ces traductions sur l\'appareil après chaque partie, pour des lancements plus rapides. Les caches occupent de l\'espace dans le préfixe du jeu. Préréglages Box64 Voir, modifier et créer des préréglages Box64 Gestionnaire de pilotes diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2a3ee0b95f..85867d5972 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1188,7 +1188,7 @@ Applica automaticamente config nota Applica automaticamente le impostazioni consigliate per i giochi Steam al primo avvio Cache di codice FEX - Condividi quale codice FEX ha tradotto e scarica traduzioni precompilate per avvii più rapidi. Le cache occupano spazio nel prefisso del gioco. + Condividi quale codice FEX ha tradotto e prepara quelle traduzioni sul dispositivo dopo aver giocato, per avvii più rapidi. Le cache occupano spazio nel prefisso del gioco. Preset Box64 Visualizza, modifica e crea preset Box64 Gestione Driver diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ecb340f51a..f37e1e2061 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1359,7 +1359,7 @@ 既知の構成を自動適用する 初回起動時に Steam ゲームの推奨設定を自動的に適用する FEX コードキャッシュ - FEX が変換したコードを共有し、ビルド済みの変換をダウンロードして起動を速くします。キャッシュはゲームのプレフィックスの容量を使います。 + FEX が変換したコードを共有し、プレイ後にその変換をこの端末で用意して起動を速くします。キャッシュはゲームのプレフィックスの容量を使います。 Box64 プリセット Box64 プリセットの表示、変更、作成 ドライバーマネージャー diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 0584c6bb0c..ab89aa8d14 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2237,7 +2237,7 @@ 알려진 설정 자동 적용 Steam 게임을 처음 실행할 때 권장 설정을 자동으로 적용합니다 FEX 코드 캐시 - FEX가 변환한 코드를 공유하고 미리 빌드된 변환을 내려받아 더 빠르게 실행합니다. 캐시는 게임 프리픽스의 저장 공간을 사용합니다. + FEX가 변환한 코드를 공유하고, 플레이 후 이 기기에서 변환을 미리 준비해 더 빠르게 실행합니다. 캐시는 게임 프리픽스의 저장 공간을 사용합니다. Wi-Fi 또는 LAN에 연결되어 있지 않습니다 다운로드 일시 중지됨 - Wi-Fi 또는 LAN 연결 대기 중 %s 다운로드 중 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 334d2e4a74..367aa8bd2d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1207,7 +1207,7 @@ Automatycznie zastosuj znaną konfigurację Automatycznie zastosuj zalecane ustawienia dla gier Steam przy pierwszym uruchomieniu Pamięci podręczne kodu FEX - Udostępniaj kod przetłumaczony przez FEX i pobieraj gotowe tłumaczenia dla szybszego uruchamiania. Pamięci podręczne zajmują miejsce w prefiksie gry. + Udostępniaj kod przetłumaczony przez FEX i przygotowuj te tłumaczenia na urządzeniu po grze, dla szybszego uruchamiania. Pamięci podręczne zajmują miejsce w prefiksie gry. Presety Box64 Przeglądaj, modyfikuj i twórz presety Box64 Menedżer sterowników diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d6254d43c8..115e114a8f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1012,7 +1012,7 @@ Aplicar configuração conhecida automaticamente Aplica automaticamente as configurações recomendadas para jogos Steam na primeira inicialização Caches de código FEX - Compartilhe qual código o FEX traduziu e baixe traduções pré-compiladas para inicializações mais rápidas. Os caches ocupam espaço no prefixo do jogo. + Compartilhe qual código o FEX traduziu e prepare essas traduções no dispositivo depois de jogar, para inicializações mais rápidas. Os caches ocupam espaço no prefixo do jogo. Predefinições do Box64 Visualizar, modificar e criar predefinições do Box64 Gerenciador de drivers diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 60d9f39c3f..a1743d0274 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1198,7 +1198,7 @@ Aplică automat configurația cunoscută Aplică automat setările recomandate pentru jocurile Steam la prima lansare Cache-uri de cod FEX - Partajează ce cod a tradus FEX și descarcă traduceri precompilate pentru porniri mai rapide. Cache-urile ocupă spațiu în prefixul jocului. + Partajează ce cod a tradus FEX și pregătește aceste traduceri pe dispozitiv după joc, pentru porniri mai rapide. Cache-urile ocupă spațiu în prefixul jocului. Presetări Box64 Vizualizează, modifică și creează presetări Box64 Manager drivere diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 40ea2d46c0..a664135eda 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1205,7 +1205,7 @@ https://gamenative.app Загрузки Автоматически применять рекомендуемые настройки для игр Steam при первом запуске Кэши кода FEX - Делитесь тем, какой код перевёл FEX, и загружайте готовые переводы для более быстрого запуска. Кэши занимают место в префиксе игры. + Делитесь тем, какой код перевёл FEX, и готовьте эти переводы на устройстве после игры для более быстрого запуска. Кэши занимают место в префиксе игры. Автоматически применять известную конфигурацию Просмотр, изменение и создание предустановок Box64 Предустановки Box64 diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 14af71053c..e9b73dd153 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1258,7 +1258,7 @@ Bilinen yapılandırmayı otomatik uygula Steam oyunları ilk başlatmada önerilen ayarları otomatik uygular FEX kod önbellekleri - FEX\'in çevirdiği kodu paylaşın ve daha hızlı başlatma için önceden derlenmiş çevirileri indirin. Önbellekler oyunun öneki içinde disk alanı kullanır. + FEX\'in çevirdiği kodu paylaşın; oynadıktan sonra bu çeviriler daha hızlı başlatma için cihazda hazırlanır. Önbellekler oyunun öneki içinde disk alanı kullanır. Box64 hazır ayarları Box64 hazır ayarlarını görüntüleyin, değiştirin ve oluşturun Sürücü yöneticisi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 08e2c13bc3..67cc1fb142 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1193,7 +1193,7 @@ Автоматично застосовувати відому конфігурацію Автоматично застосовувати рекомендовані налаштування для ігор Steam при першому запуску Кеші коду FEX - Діліться тим, який код переклав FEX, і завантажуйте готові переклади для швидшого запуску. Кеші займають місце в префіксі гри. + Діліться тим, який код переклав FEX, і готуйте ці переклади на пристрої після гри для швидшого запуску. Кеші займають місце в префіксі гри. Box64 Пресети Перегляд, зміна та створення пресетів Box64 Менеджер драйвера diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a79c713298..1d8ff7aa2e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1181,7 +1181,7 @@ 自动匹配预设配置 首次运行 Steam 游戏时,自动加载并应用社区推荐的最佳设置 FEX 代码缓存 - 分享 FEX 翻译过的代码并下载预编译的翻译以加快启动。缓存会占用游戏前缀中的存储空间。 + 分享 FEX 翻译过的代码,并在游戏结束后在本设备上预先准备这些翻译以加快启动。缓存会占用游戏前缀中的存储空间。 Box64 预设集 查看、修改及创建 Box64 预设集 驱动程序管理器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a970697c32..9d83eebb70 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1183,7 +1183,7 @@ 自動套用預設配置 首次執行 Steam 遊戲時,自動載入並套用社群推薦的最佳設定 FEX 程式碼快取 - 分享 FEX 翻譯過的程式碼並下載預先編譯的翻譯以加快啟動。快取會佔用遊戲前綴中的儲存空間。 + 分享 FEX 翻譯過的程式碼,並在遊戲結束後於本裝置預先準備這些翻譯以加快啟動。快取會佔用遊戲前綴中的儲存空間。 Box64 預設集 檢視、修改及建立 Box64 預設集 驅動程式管理員 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21cfa8b90d..6fa2aba605 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1370,7 +1370,7 @@ Auto-apply known config Automatically apply recommended settings for Steam games on first launch FEX code caches - Share which code FEX translated and download prebuilt translations for faster launches. Caches take disk space in the game\'s prefix. + Share which code FEX translated and prepare those translations on this device after you play, for faster launches. Caches take disk space in the game\'s prefix. Box64 Presets View, modify, and create Box64 presets Driver Manager diff --git a/app/src/test/java/app/gamenative/shadercache/ShaderCacheKeyTest.kt b/app/src/test/java/app/gamenative/shadercache/ShaderCacheKeyTest.kt index b0b0bff86a..8c6e4e85e0 100644 --- a/app/src/test/java/app/gamenative/shadercache/ShaderCacheKeyTest.kt +++ b/app/src/test/java/app/gamenative/shadercache/ShaderCacheKeyTest.kt @@ -25,6 +25,21 @@ class ShaderCacheKeyTest { assertNull(ShaderCacheKey.storeGameKey("EPIC_7") { _, _ -> null }) } + @Test fun installedStoreGamesMatchTheContainerKeys() { + assertEquals(ShaderCacheKey.storeGameKey("STEAM_221380", localIds), ShaderCacheKey.storeGame("steam", "221380")) + assertEquals( + ShaderCacheKey.storeGameKey("AMAZON_12") { _, _ -> ShaderCacheKey.amazonGameId("amzn1.adg.product.08c0fa3b-3448-19b7") }, + ShaderCacheKey.storeGame("amazon", ShaderCacheKey.amazonGameId("amzn1.adg.product.08c0fa3b-3448-19b7")), + ) + assertEquals("amazon:08c0fa3b-3448-19b7", ShaderCacheKey.storeGame("amazon", ShaderCacheKey.amazonGameId("amzn1.adg.product.08c0fa3b-3448-19b7"))) + assertNull(ShaderCacheKey.storeGame("epic", "bad name")) + } + + @Test fun poolFileNameIsTheGameOnly() { + assertEquals("steam_221380", ShaderCacheKey("steam:221380").fileName) + assertEquals("custom_ab12", ShaderCacheKey("custom:ab12").fileName) + } + @Test fun dxvkVersions() { assertEquals("dxvk-2.7.1", ShaderCacheKey.dxvkKey("2.7.1-1-gplasync-0")) assertEquals("dxvk-2.7.1", ShaderCacheKey.dxvkKey("dxvk-2.7.1")) diff --git a/app/src/test/java/app/gamenative/utils/FexImagesTest.kt b/app/src/test/java/app/gamenative/utils/FexImagesTest.kt deleted file mode 100644 index 016c581638..0000000000 --- a/app/src/test/java/app/gamenative/utils/FexImagesTest.kt +++ /dev/null @@ -1,214 +0,0 @@ -package app.gamenative.utils - -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -class FexImagesTest { - - @get:Rule - val tmp = TemporaryFolder() - - private val peOffset = 0x80 - - /** DOS header, NT signature, COFF header and the optional header up to SizeOfImage; nothing else. */ - private fun pe(timeDateStamp: Long, sizeOfImage: Long, pe32Plus: Boolean = true, padding: Int = 0): ByteArray { - val buf = ByteBuffer.allocate(peOffset + 24 + 240 + padding).order(ByteOrder.LITTLE_ENDIAN) - buf.put(0, 'M'.code.toByte()).put(1, 'Z'.code.toByte()) - buf.putInt(0x3c, peOffset) - buf.putInt(peOffset, 0x00004550) - buf.putShort(peOffset + 4, (if (pe32Plus) 0x8664 else 0x14c).toShort()) - buf.putInt(peOffset + 8, timeDateStamp.toInt()) - buf.putShort(peOffset + 24, (if (pe32Plus) 0x20b else 0x10b).toShort()) - buf.putInt(peOffset + 24 + 56, sizeOfImage.toInt()) - return buf.array() - } - - private class Section(val name: String, val virtualAddress: Int, val bytes: ByteArray) - - /** A PE32+ image with a 240-byte optional header followed by [sections], raw data packed after the table. */ - private fun peWithSections(sections: List
, timeDateStamp: Long = 1, truncateBy: Int = 0): ByteArray { - val optionalHeaderSize = 240 - val tableOffset = peOffset + 24 + optionalHeaderSize - val dataStart = tableOffset + sections.size * 40 - val total = dataStart + sections.sumOf { it.bytes.size } - val buf = ByteBuffer.allocate(total).order(ByteOrder.LITTLE_ENDIAN) - buf.put(0, 'M'.code.toByte()).put(1, 'Z'.code.toByte()) - buf.putInt(0x3c, peOffset) - buf.putInt(peOffset, 0x00004550) - buf.putShort(peOffset + 4, 0x8664.toShort()) - buf.putShort(peOffset + 6, sections.size.toShort()) - buf.putInt(peOffset + 8, timeDateStamp.toInt()) - buf.putShort(peOffset + 20, optionalHeaderSize.toShort()) - buf.putShort(peOffset + 24, 0x20b.toShort()) - buf.putInt(peOffset + 24 + 56, 0x10000) - var data = dataStart - sections.forEachIndexed { i, section -> - val h = tableOffset + i * 40 - section.name.toByteArray().forEachIndexed { j, b -> buf.put(h + j, b) } - buf.putInt(h + 8, section.bytes.size) - buf.putInt(h + 12, section.virtualAddress) - buf.putInt(h + 16, section.bytes.size) - buf.putInt(h + 20, data) - section.bytes.forEachIndexed { j, b -> buf.put(data + j, b) } - data += section.bytes.size - } - return buf.array().copyOf(total - truncateBy) - } - - private fun file(name: String, bytes: ByteArray) = File(tmp.root, name).apply { - parentFile?.mkdirs() - writeBytes(bytes) - } - - private fun sha256(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } - - @Test - fun `reads TimeDateStamp and SizeOfImage from a PE32+ header`() { - val header = FexImages.readHeader(file("game.exe", pe(0x66778899, 41_000_000))) - assertEquals(FexImages.Header(0x66778899, 41_000_000), header) - } - - @Test - fun `reads the same offsets from a PE32 header`() { - val header = FexImages.readHeader(file("game32.exe", pe(1_719_000_000, 0x2000, pe32Plus = false))) - assertEquals(FexImages.Header(1_719_000_000, 0x2000), header) - } - - @Test - fun `header fields are unsigned`() { - val header = FexImages.readHeader(file("high.dll", pe(0xF0000000L, 0x80000000L))) - assertEquals(FexImages.Header(0xF0000000L, 0x80000000L), header) - } - - @Test - fun `rejects files without the MZ or PE signatures`() { - val noMz = pe(1, 2).also { it[0] = 'X'.code.toByte() } - val noPe = pe(1, 2).also { it[peOffset + 2] = 1 } - assertNull(FexImages.readHeader(file("nomz.exe", noMz))) - assertNull(FexImages.readHeader(file("nope.exe", noPe))) - assertNull(FexImages.readHeader(file("text.dll", "not a pe".toByteArray()))) - } - - @Test - fun `rejects a header that runs past the end of the file`() { - assertNull(FexImages.readHeader(file("short.exe", pe(1, 2).copyOf(peOffset + 24 + 40)))) - val farLfanew = pe(1, 2).also { ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).putInt(0x3c, -16) } - assertNull(FexImages.readHeader(file("far.exe", farLfanew))) - } - - @Test - fun `describes PE files with sha256, lowercase name and header fields and skips the rest`() { - val bytes = pe(1_719_000_000, 41_000_000) - val exe = file("game/Brotato.exe", bytes) - val text = file("game/readme.dll", "hello".toByteArray()) - val images = FexImages.describe(tmp.root, listOf(exe, text)) - assertEquals( - listOf(FexImages.Image(sha256(bytes), sha256(ByteArray(0)), "brotato.exe", 1_719_000_000, 41_000_000)), - images, - ) - - val index = JSONObject(File(tmp.root, ".gamenative/fex-image-hashes.json").readText()) - val entry = index.getJSONObject(exe.absolutePath) - assertEquals(bytes.size.toLong(), entry.getLong("size")) - assertEquals(exe.lastModified(), entry.getLong("mtime")) - assertEquals(sha256(bytes), entry.getString("sha256")) - assertEquals(sha256(ByteArray(0)), entry.getString("codeSha256")) - assertFalse(index.has(text.absolutePath)) - } - - private fun sections(text: ByteArray, rsrc: ByteArray) = listOf( - Section(".text", 0x1000, text), - Section(".rsrc", 0x3000, rsrc), - Section(".reloc", 0x4000, ByteArray(16) { 7 }), - ) - - @Test - fun `codeSha256 hashes section headers and raw bytes except rsrc and reloc`() { - val text = ByteArray(64) { it.toByte() } - val bytes = peWithSections(sections(text, ByteArray(32) { 1 })) - val fields = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).putInt(0x1000).putInt(64).putInt(64).array() - assertEquals(sha256(fields + text), FexImages.codeSha256(file("game/a.exe", bytes))) - } - - @Test - fun `changing rsrc keeps codeSha256 and changes sha256`() { - val text = ByteArray(64) { it.toByte() } - val a = file("game/a.exe", peWithSections(sections(text, ByteArray(32) { 1 }))) - val b = file("other/a.exe", peWithSections(sections(text, ByteArray(32) { 2 }), timeDateStamp = 99)) - val (imageA, imageB) = FexImages.describe(tmp.root, listOf(a, b)) - assertEquals(imageA.codeSha256, imageB.codeSha256) - assertTrue(imageA.sha256 != imageB.sha256) - } - - @Test - fun `changing text changes both hashes`() { - val a = file("game/a.exe", peWithSections(sections(ByteArray(64) { 1 }, ByteArray(32)))) - val b = file("other/a.exe", peWithSections(sections(ByteArray(64) { 3 }, ByteArray(32)))) - val (imageA, imageB) = FexImages.describe(tmp.root, listOf(a, b)) - assertTrue(imageA.codeSha256 != imageB.codeSha256) - assertTrue(imageA.sha256 != imageB.sha256) - } - - @Test - fun `a section cut short by the end of the file is padded with zeros`() { - val text = ByteArray(64) { 5 }.also { it.fill(0, 48, 64) } - val whole = peWithSections(listOf(Section(".text", 0x1000, text))) - val cut = peWithSections(listOf(Section(".text", 0x1000, text)), truncateBy = 16) - assertEquals(FexImages.codeSha256(file("whole.exe", whole)), FexImages.codeSha256(file("cut.exe", cut))) - } - - @Test - fun `an index entry without codeSha256 is rehashed`() { - val exe = file("game/game.exe", peWithSections(sections(ByteArray(8) { 1 }, ByteArray(8)))) - val expected = FexImages.describe(tmp.root, listOf(exe)).single() - val indexFile = File(tmp.root, ".gamenative/fex-image-hashes.json") - val index = JSONObject(indexFile.readText()) - index.getJSONObject(exe.absolutePath).remove("codeSha256") - indexFile.writeText(index.toString()) - assertEquals(expected, FexImages.describe(tmp.root, listOf(exe)).single()) - assertEquals(expected.codeSha256, JSONObject(indexFile.readText()).getJSONObject(exe.absolutePath).getString("codeSha256")) - } - - @Test - fun `an unchanged file is not hashed again and a changed one is`() { - val exe = file("game/game.exe", pe(10, 20)) - FexImages.describe(tmp.root, listOf(exe)) - - // Swap the stored hash: if the index is used, the swapped value comes back. - val indexFile = File(tmp.root, ".gamenative/fex-image-hashes.json") - val marker = "a".repeat(64) - val index = JSONObject(indexFile.readText()) - index.getJSONObject(exe.absolutePath).put("sha256", marker) - indexFile.writeText(index.toString()) - assertEquals(marker, FexImages.describe(tmp.root, listOf(exe)).single().sha256) - - val updated = pe(11, 20, padding = 16) - exe.writeBytes(updated) - exe.setLastModified(exe.lastModified() + 2000) - val image = FexImages.describe(tmp.root, listOf(exe)).single() - assertEquals(sha256(updated), image.sha256) - assertEquals(11L, image.timeDateStamp) - } - - @Test - fun `entries of deleted files are dropped from the index`() { - val a = file("game/a.dll", pe(1, 2)) - val b = file("game/b.dll", pe(3, 4)) - FexImages.describe(tmp.root, listOf(a, b)) - assertTrue(b.delete()) - FexImages.describe(tmp.root, listOf(a)) - val index = JSONObject(File(tmp.root, ".gamenative/fex-image-hashes.json").readText()) - assertTrue(index.has(a.absolutePath)) - assertFalse(index.has(b.absolutePath)) - } -} From f8db252f311cdb09074f4ef50c8ac42c8645db44 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 10:49:01 +0530 Subject: [PATCH 04/10] Run the FEX cache compile in the overlay's own spelling of the prefix path A thin container's prefix is only overlaid on its base prefix when WINEPREFIX matches the overlay's upper dir or one of its aliases. The compile passed the non-canonical imagefs/home path, so Wine saw only the upper layer and stopped at kernel32.dll. Use the canonical host path the overlay is set up with. --- app/src/main/java/app/gamenative/utils/FexCodeCaches.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt index 9e9c3284f7..3a80309dbd 100644 --- a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt +++ b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt @@ -6,6 +6,7 @@ import android.system.Os import app.gamenative.PluviaApp import app.gamenative.PrefManager import com.winlator.container.Container +import com.winlator.container.ContainerOverlay import com.winlator.contents.ContentsManager import com.winlator.core.ProcessHelper import com.winlator.core.envvars.EnvVars @@ -412,8 +413,10 @@ object FexCodeCaches { ) val started = SystemClock.elapsedRealtime() val env = EnvVars().apply { - // The real prefix path, not the home symlink, which a launch of another container repoints. - put("WINEPREFIX", File(container.rootDir, ".wine").absolutePath) + // The real prefix path, not the home symlink, which a launch of another container repoints. It must + // be the canonical spelling the overlay uses as its upper dir: any other spelling of a thin prefix + // is not overlaid, so Wine sees no base prefix and cannot load kernel32. + put("WINEPREFIX", ContainerOverlay.canonicalHostPath(File(container.rootDir, ".wine"))) put("FEX_APP_CONFIG_LOCATION", "$WIN_WORK_DIR\\") put("WINEDEBUG", "+err") } From e57deea611149c621696920f1f405406182559cf Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 11:17:04 +0530 Subject: [PATCH 05/10] Log a skipped pool download as skipped, not as a failure --- .../java/app/gamenative/shadercache/FossilizeManager.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index 35480178fa..5045ab0b7f 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -313,7 +313,11 @@ object FossilizeManager { val hash = if (result is ApiResult.Success) result.data else null if (hash == null || !hash.equals(pool.sha256, ignoreCase = true)) { partial.delete() - Timber.tag(TAG).w("Pool download for %s failed (%s)", key, if (hash == null) result else "sha256 mismatch") + if (hash == null && shouldStop()) { + Timber.tag(TAG).i("Pool download for %s skipped or cancelled", key) + } else { + Timber.tag(TAG).w("Pool download for %s failed (%s)", key, if (hash == null) result else "sha256 mismatch") + } return local } poolDir(container).listFiles()?.forEach { if (it != partial) it.delete() } From a0651979ddaf4b7a0287b880c85607b28e4e4882 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 11:34:29 +0530 Subject: [PATCH 06/10] Download a game's shader pool as the last step of its install The install downloads the game's pool into app storage after the cloud save step (Steam) or just before the install is marked complete (GOG, Epic, Amazon), shown as a post-install step. It never fails an install, is bounded like the launch download, and stops when the install is cancelled or paused. The first launch moves the staged pool into the container and waits on a download still in flight instead of starting a second one. Staged pools are removed on uninstall and, at app start, for games no longer installed. --- .../app/gamenative/service/SteamService.kt | 3 + .../service/amazon/AmazonDownloadManager.kt | 8 + .../service/amazon/AmazonService.kt | 6 + .../service/epic/EpicDownloadManager.kt | 2 + .../gamenative/service/epic/EpicService.kt | 2 + .../service/gog/GOGDownloadManager.kt | 1 + .../app/gamenative/service/gog/GOGService.kt | 1 + .../shadercache/FossilizeManager.kt | 146 +++++++++++++++++- .../app/gamenative/shadercache/StagedPools.kt | 71 +++++++++ .../gamenative/shadercache/StagedPoolsTest.kt | 85 ++++++++++ 10 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/gamenative/shadercache/StagedPools.kt create mode 100644 app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index f6a2b2a45b..8c80c1f2d0 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -91,6 +91,7 @@ import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesFamilygroupsS import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesParentalSteamclient import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesPlayerSteamclient import app.gamenative.data.SteamCollectionRepository +import app.gamenative.shadercache.FossilizeManager import app.gamenative.steam.CloudConfigStoreService import app.gamenative.steam.SteamCollectionParser import `in`.dragonbra.javasteam.rpc.service.FamilyGroups @@ -2130,6 +2131,7 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { // the close-time cloud sync uploads out of this install dir; deleting mid-upload leaves it half sent. CloseSyncTracker.awaitIdle(CloseSyncTracker.keyOf(GameSource.STEAM, appId)) + instance?.let { FossilizeManager.dropStagedPool(it.applicationContext, "steam", appId.toString()) } // snapshot path before marker removal (removing the marker changes resolution) val appInfo = getInstalledApp(appId) val result = if (appInfo?.isImported == true) { @@ -3162,6 +3164,7 @@ class SteamService : Service(), IChallengeUrlChanged { PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(appId, false)) } } + FossilizeManager.downloadPoolDuringInstall(svc.applicationContext, downloadInfo, containerId, "steam", appId.toString()) } else { Timber.d("Skipped container creation Lossless Scaling") PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(appId, false)) diff --git a/app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt b/app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt index aa24dc288b..571f267ef1 100644 --- a/app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt @@ -260,6 +260,14 @@ class AmazonDownloadManager @Inject constructor( Timber.tag(TAG).w(e, "Failed to cache manifest (non-fatal)") } + app.gamenative.shadercache.FossilizeManager.downloadPoolDuringInstall( + context, + downloadInfo, + "AMAZON_${game.appId}", + "amazon", + app.gamenative.shadercache.ShaderCacheKey.amazonGameId(productId), + ) + // ── 7. Persist installed state ─────────────────────────────────── Timber.tag(TAG).i("Persisting install: productId=$productId, version=${spec.versionId}") amazonManager.markInstalled(productId, installPath, manifest.totalInstallSize, spec.versionId) diff --git a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt index ed8e69b257..863f61afd2 100644 --- a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt +++ b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt @@ -564,6 +564,12 @@ class AmazonService : Service() { val game = instance.amazonManager.getGameById(productId) ?: return@withContext Result.failure(Exception("Game not found: $productId")) + app.gamenative.shadercache.FossilizeManager.dropStagedPool( + context, + "amazon", + app.gamenative.shadercache.ShaderCacheKey.amazonGameId(productId), + ) + // Remove download from GameDownloadService GameDownloadService.removeDownload(context, GameSource.AMAZON, productId) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index 3e54a2f377..5026f11345 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -411,6 +411,8 @@ class EpicDownloadManager @Inject constructor( ), ) + app.gamenative.shadercache.FossilizeManager.downloadPoolDuringInstall(context, downloadInfo, "EPIC_${game.id}", "epic", game.appName) + // Update database with install info try { val updatedGame = game.copy( diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 87b4783d17..6772cc9d37 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -254,6 +254,8 @@ class EpicService : Service() { return Result.failure(Exception("Game not found: $appId")) } + app.gamenative.shadercache.FossilizeManager.dropStagedPool(context, "epic", game.appName) + // Remove download from GameDownloadService GameDownloadService.removeDownload(context, GameSource.EPIC, appId.toString()) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 4a6db9232d..652d1ed4ef 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -841,6 +841,7 @@ class GOGDownloadManager @Inject constructor( * Used by both Gen 2 and Gen 1 success paths. */ private suspend fun finalizeInstallSuccess(gameId: String, installPath: File, downloadInfo: DownloadInfo) { + app.gamenative.shadercache.FossilizeManager.downloadPoolDuringInstall(context, downloadInfo, "GOG_$gameId", "gog", gameId) downloadInfo.updateStatusMessage("Updating database...") try { val game = gogManager.getGameFromDbById(gameId) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 0a1b2b69b9..445cad258d 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -546,6 +546,7 @@ class GOGService : Service() { app.gamenative.service.cloud.CloseSyncTracker.awaitIdle( app.gamenative.service.cloud.CloseSyncTracker.keyOf(GameSource.GOG, libraryItem.gameId), ) + app.gamenative.shadercache.FossilizeManager.dropStagedPool(context, "gog", libraryItem.gameId.toString()) return getInstance()?.gogManager?.deleteGame(context, libraryItem) ?: Result.failure(Exception("Service not available")) } diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index 5045ab0b7f..ab86c70bc5 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -8,6 +8,7 @@ import app.gamenative.R import app.gamenative.api.ApiResult import app.gamenative.api.ShaderCacheApi import app.gamenative.api.SupportApi +import app.gamenative.data.DownloadInfo import app.gamenative.db.dao.AmazonGameDao import app.gamenative.db.dao.AppInfoDao import app.gamenative.db.dao.EpicGameDao @@ -32,12 +33,16 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -50,6 +55,8 @@ import timber.log.Timber /** * Shared shader cache built on Valve's Fossilize. * + * Install: as the last step of a game install the game's pool is downloaded into app storage; the first launch moves + * it into the container. * App start: one request asks which installed games have a newer pool; nothing is downloaded. * Launch: the capture layer is installed per container as an implicit layer and records every * pipeline the game creates into a fresh session database, skipping entries already in the @@ -102,6 +109,7 @@ object FossilizeManager { private val remotePools = ConcurrentHashMap() private val checkedGames: MutableSet = ConcurrentHashMap.newKeySet() private val startCheckRan = AtomicBoolean(false) + private val stagingJobs = ConcurrentHashMap() @Volatile private var activeSessionDir: File? = null @@ -280,8 +288,35 @@ object FossilizeManager { ): JSONObject? { val metaFile = poolMetaFile(container, key) dropStalePoolFiles(container, key) - val local = runCatching { JSONObject(metaFile.readText()) }.getOrNull() + val current = runCatching { JSONObject(metaFile.readText()) }.getOrNull() ?.takeIf { poolFile(container, key).isFile } + val staging = stagingJobs[key.game] + if (staging != null && staging.isActive) { + // The game's install is still downloading this pool; a second download would race it. + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) + LaunchSkip.offer { skipped.set(true) } + awaitWork(POOL_DOWNLOAD_TIMEOUT_MS, shouldStop, cancelOnStop = false) { staging.join() } + if (staging.isActive) { + Timber.tag(TAG).i("Install-time pool download for %s still running; launching without it", key) + return current + } + } + val local = StagedPools.adopt( + StagedPools.dir(context.filesDir), + key, + poolDir(container), + poolFile(container, key), + metaFile, + current?.optLong("version"), + )?.also { adopted -> + val version = adopted.optLong("version") + if (key.game in checkedGames) { + remotePools.merge(key.game, ShaderCacheApi.PoolVersion(version, adopted.optLong("size"), adopted.optLong("entries"))) { old, new -> + if (new.version >= old.version) new else old + } + } + Timber.tag(TAG).i("Adopted staged pool v%d for %s", version, key) + } ?: current if (key.game in checkedGames) { val known = remotePools[key.game] ?: return local if (local != null && local.optLong("version") >= known.version) return local @@ -335,6 +370,112 @@ object FossilizeManager { return meta } + /** + * Install step: downloads the pool of the game being installed into app storage, where its first launch picks it + * up. Shows on the install as a post-install step. Ends quietly on any failure; cancelling the install cancels it. + */ + suspend fun downloadPoolDuringInstall(context: Context, downloadInfo: DownloadInfo, containerId: String, store: String, gameId: String) { + if (!PrefManager.sharedShaderCache) return + val key = ShaderCacheKey.storeGame(store, gameId)?.let(::ShaderCacheKey) ?: return + val appContext = context.applicationContext + downloadInfo.setPostInstallSyncing(true) + downloadInfo.updateStatusMessage(appContext.getString(R.string.shader_cache_preparing)) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(downloadInfo.gameId, true)) + try { + coroutineScope { + val step = async(start = CoroutineStart.LAZY) { stagePool(appContext, downloadInfo, containerId, key) } + val running = stagingJobs.putIfAbsent(key.game, step) + if (running != null) { + step.cancel() + running.join() + return@coroutineScope + } + try { + step.await() + } finally { + stagingJobs.remove(key.game, step) + } + } + } catch (e: CancellationException) { + // Only the install's own cancellation propagates; an uninstall cancelling the step just ends it. + currentCoroutineContext().ensureActive() + } catch (t: Throwable) { + Timber.tag(TAG).w(t, "Install-time pool download for %s failed", key) + } finally { + downloadInfo.setPostInstallSyncing(false) + downloadInfo.updateStatusMessage(null) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(downloadInfo.gameId, false)) + } + } + + private suspend fun stagePool(context: Context, downloadInfo: DownloadInfo, containerId: String, key: ShaderCacheKey) { + val dir = StagedPools.dir(context.filesDir) + val container = runCatching { ContainerManager(context).getContainerById(containerId) }.getOrNull() + val local = container?.let { c -> + StagedPools.readMeta(poolMetaFile(c, key))?.takeIf { poolFile(c, key).isFile }?.optLong("version") + } + // Abandoned rather than cancelled on timeout, as at launch: the lookup can include a token refresh. + val lookup = launchWorkScope.async { ShaderCacheApi.fetchPool(key) } + val remote = withTimeoutOrNull(POOL_LOOKUP_TIMEOUT_MS) { lookup.await() } + if (remote !is ApiResult.Success) { + Timber.tag(TAG).i("Install-time pool lookup for %s failed (%s)", key, remote ?: "timed out") + return + } + rememberPool(key.game, remote.data) + val pool = remote.data ?: run { + Timber.tag(TAG).i("No pool for %s at install", key) + return + } + val have = maxOf(local ?: -1L, StagedPools.stagedVersion(dir, key) ?: -1L) + if (have >= pool.version) { + Timber.tag(TAG).i("Pool v%d for %s is already on the device", have, key) + return + } + if (pool.size > MAX_TRANSFER_BYTES) { + Timber.tag(TAG).w("Pool for %s is %d bytes, over the %d byte limit", key, pool.size, MAX_TRANSFER_BYTES) + return + } + val target = StagedPools.poolFile(dir, key) + val partial = File(dir, target.name + ".part") + var shown = -1 + val result = withTimeoutOrNull(POOL_DOWNLOAD_TIMEOUT_MS) { + SupportApi.downloadSigned( + pool.url, + partial, + if (pool.size > 0) pool.size else MAX_TRANSFER_BYTES, + client = Net.launchDownload, + ) { progress -> + val percent = (progress * 100).toInt() + if (percent != shown) { + shown = percent + downloadInfo.updateStatusMessage(context.getString(R.string.shader_cache_preparing_progress, percent)) + } + } + } + val hash = (result as? ApiResult.Success)?.data + if (hash == null || !hash.equals(pool.sha256, ignoreCase = true)) { + partial.delete() + Timber.tag(TAG).w("Install-time pool download for %s failed (%s)", key, if (hash == null) result ?: "timed out" else "sha256 mismatch") + return + } + StagedPools.metaFile(dir, key).delete() + if (!partial.renameTo(target)) { + partial.delete() + return + } + FileUtils.writeString(StagedPools.metaFile(dir, key), StagedPools.meta(pool.version, pool.sha256, target.length(), pool.entries).toString()) + Timber.tag(TAG).i("Staged pool v%d for %s (%d entries, %d bytes)", pool.version, key, pool.entries, target.length()) + } + + /** Deletes the staged pool of an uninstalled game. Never throws. */ + fun dropStagedPool(context: Context, store: String, gameId: String) { + val key = ShaderCacheKey.storeGame(store, gameId)?.let(::ShaderCacheKey) ?: return + runCatching { + stagingJobs[key.game]?.cancel() + StagedPools.drop(StagedPools.dir(context.filesDir), key) + }.onFailure { Timber.tag(TAG).w(it, "Could not delete the staged pool of %s", key) } + } + private fun rememberPool(game: String, pool: ShaderCacheApi.Pool?) { if (pool == null) { remotePools.remove(game) @@ -365,6 +506,9 @@ object FossilizeManager { try { delay(START_CHECK_DELAY_MS) val games = installedGames(context, appInfoDao, gogGameDao, epicGameDao, amazonGameDao) + runCatching { + StagedPools.dropAllExcept(StagedPools.dir(context.filesDir), (games + stagingJobs.keys).map(::ShaderCacheKey)) + } for (chunk in games.chunked(ShaderCacheApi.MAX_CHECK_GAMES)) { val result = ShaderCacheApi.checkPools(chunk) if (result !is ApiResult.Success) { diff --git a/app/src/main/java/app/gamenative/shadercache/StagedPools.kt b/app/src/main/java/app/gamenative/shadercache/StagedPools.kt new file mode 100644 index 0000000000..8157701280 --- /dev/null +++ b/app/src/main/java/app/gamenative/shadercache/StagedPools.kt @@ -0,0 +1,71 @@ +package app.gamenative.shadercache + +import java.io.File +import org.json.JSONObject + +/** + * Pools downloaded when a game finishes installing. A game has no container until its first launch, so the pool waits + * in app storage and that launch moves it into the container's pool directory. + */ +internal object StagedPools { + fun dir(filesDir: File) = File(filesDir, "shadercache/staged") + fun poolFile(dir: File, key: ShaderCacheKey) = File(dir, "${key.fileName}.foz") + fun metaFile(dir: File, key: ShaderCacheKey) = File(dir, "${key.fileName}.json") + + fun meta(version: Long, sha256: String, size: Long, entries: Long): JSONObject = JSONObject() + .put("version", version) + .put("sha256", sha256) + .put("size", size) + .put("entries", entries) + + fun readMeta(file: File): JSONObject? = + if (file.isFile) runCatching { JSONObject(file.readText()) }.getOrNull() else null + + /** Version of the complete staged pool of [key], or null if there is none. */ + fun stagedVersion(dir: File, key: ShaderCacheKey): Long? = + readMeta(metaFile(dir, key))?.takeIf { poolFile(dir, key).isFile }?.optLong("version") + + /** + * Moves the staged pool of [key] into [poolDir] as [poolFile] with [poolMetaFile] when it is newer than + * [localVersion], replacing everything else in [poolDir]. Returns the adopted pool's metadata, or null when nothing + * was adopted. A staged pool that is incomplete, no newer, or whose size does not match its metadata is deleted. + */ + fun adopt(dir: File, key: ShaderCacheKey, poolDir: File, poolFile: File, poolMetaFile: File, localVersion: Long?): JSONObject? { + val staged = poolFile(dir, key) + val stagedMeta = metaFile(dir, key) + if (!staged.exists() && !stagedMeta.exists()) return null + val meta = readMeta(stagedMeta) + val usable = meta != null && staged.isFile && staged.length() == meta.optLong("size", -1L) && + (localVersion == null || meta.optLong("version") > localVersion) + if (!usable) { + drop(dir, key) + return null + } + poolDir.mkdirs() + poolDir.listFiles()?.forEach { if (it.isFile) it.delete() } + val moved = staged.renameTo(poolFile) || runCatching { + staged.copyTo(poolFile, overwrite = true) + staged.delete() + }.isSuccess + if (!moved) { + poolFile.delete() + drop(dir, key) + return null + } + poolMetaFile.writeText(meta.toString()) + stagedMeta.delete() + return meta + } + + fun drop(dir: File, key: ShaderCacheKey) { + poolFile(dir, key).delete() + metaFile(dir, key).delete() + File(dir, "${key.fileName}.foz.part").delete() + } + + /** Deletes every staged file that does not belong to one of [keep]. */ + fun dropAllExcept(dir: File, keep: Collection) { + val names = keep.flatMap { listOf("${it.fileName}.foz", "${it.fileName}.json", "${it.fileName}.foz.part") }.toSet() + dir.listFiles()?.forEach { if (it.isFile && it.name !in names) it.delete() } + } +} diff --git a/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt b/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt new file mode 100644 index 0000000000..b9c5a46aab --- /dev/null +++ b/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt @@ -0,0 +1,85 @@ +package app.gamenative.shadercache + +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class StagedPoolsTest { + @get:Rule val temp = TemporaryFolder() + + private val key = ShaderCacheKey("steam:242760") + + private val staged by lazy { temp.newFolder("staged") } + private val poolDir by lazy { File(temp.root, "container/.cache/fossilize/pool") } + private val poolFile by lazy { File(poolDir, "${key.fileName}.foz") } + private val poolMeta by lazy { File(poolDir, "${key.fileName}.json") } + + private fun stage(version: Long, bytes: ByteArray, size: Long = bytes.size.toLong()) { + StagedPools.poolFile(staged, key).writeBytes(bytes) + StagedPools.metaFile(staged, key).writeText(StagedPools.meta(version, "ab", size, 7).toString()) + } + + private fun adopt(localVersion: Long?) = StagedPools.adopt(staged, key, poolDir, poolFile, poolMeta, localVersion) + + @Test fun adoptsAStagedPoolNewerThanTheContainers() { + poolDir.mkdirs() + poolFile.writeBytes(byteArrayOf(1)) + File(poolDir, "local.foz").writeBytes(byteArrayOf(2)) + val bytes = ByteArray(100) { it.toByte() } + stage(5, bytes) + + val meta = adopt(localVersion = 4) + + assertEquals(5L, meta?.optLong("version")) + assertArrayEquals(bytes, poolFile.readBytes()) + assertEquals(5L, JSONObject(poolMeta.readText()).getLong("version")) + assertEquals(100L, JSONObject(poolMeta.readText()).getLong("size")) + assertFalse(File(poolDir, "local.foz").exists()) + assertTrue(staged.listFiles().isNullOrEmpty()) + } + + @Test fun adoptsIntoAContainerWithoutAPool() { + stage(3, ByteArray(10)) + + assertEquals(3L, adopt(localVersion = null)?.optLong("version")) + assertTrue(poolFile.isFile) + } + + @Test fun ignoresAStagedPoolTheContainerAlreadyHas() { + poolDir.mkdirs() + poolFile.writeBytes(byteArrayOf(9)) + stage(5, ByteArray(10)) + + assertNull(adopt(localVersion = 5)) + assertArrayEquals(byteArrayOf(9), poolFile.readBytes()) + assertFalse(poolMeta.exists()) + assertFalse(StagedPools.poolFile(staged, key).exists()) + } + + @Test fun rejectsAStagedPoolWhoseSizeDoesNotMatch() { + stage(5, ByteArray(10), size = 11) + + assertNull(adopt(localVersion = null)) + assertFalse(poolFile.exists()) + assertFalse(StagedPools.poolFile(staged, key).exists()) + assertFalse(StagedPools.metaFile(staged, key).exists()) + } + + @Test fun dropsStagedPoolsOfGamesNoLongerInstalled() { + stage(1, ByteArray(4)) + val other = ShaderCacheKey("gog:1") + StagedPools.poolFile(staged, other).writeBytes(ByteArray(4)) + File(staged, "${other.fileName}.foz.part").writeBytes(ByteArray(4)) + + StagedPools.dropAllExcept(staged, listOf(key)) + + assertEquals(setOf("${key.fileName}.foz", "${key.fileName}.json"), staged.list()!!.toSet()) + } +} From aa097ccc67914500d48f59459206c120b6cee81a Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 12:14:51 +0530 Subject: [PATCH 07/10] Build FEX code caches on the loading screen before the game starts The build that ran only in the background after a session never ran when the app was closed right after a game. A FEXCore launch now merges any waiting maps and compiles the images without a current cache before the guest starts, with its progress on the booting splash and a Skip button. Skip or leaving the launch stops the compile at once; finished caches stay and the rest waits for the next build. The background build after a session stays; a launch still stops it and picks up what it left. A cache is rebuilt only once its image recorded 10% more blocks than it was built from, since every compile rebuilds the whole cache. The single FEX setting becomes two: uploading code maps (on by default, keeps the old setting's stored value) and using code caches (experimental, off by default, unavailable while the upload is off). While caches are off, a container's built caches are deleted at its next launch, because the switch that records maps also loads caches. --- .../main/java/app/gamenative/PrefManager.kt | 21 +- .../screen/settings/SettingsGroupEmulation.kt | 25 +- .../ui/screen/xserver/XServerScreen.kt | 5 + .../app/gamenative/utils/FexCodeCaches.kt | 292 +++++++++++++++--- .../java/app/gamenative/utils/FexCodeMaps.kt | 8 +- .../BionicProgramLauncherComponent.java | 1 + app/src/main/res/values-da/strings.xml | 7 +- app/src/main/res/values-de/strings.xml | 7 +- app/src/main/res/values-es/strings.xml | 7 +- app/src/main/res/values-fr/strings.xml | 7 +- app/src/main/res/values-it/strings.xml | 7 +- app/src/main/res/values-ja/strings.xml | 7 +- app/src/main/res/values-ko/strings.xml | 7 +- app/src/main/res/values-pl/strings.xml | 7 +- app/src/main/res/values-pt-rBR/strings.xml | 7 +- app/src/main/res/values-ro/strings.xml | 7 +- app/src/main/res/values-ru/strings.xml | 7 +- app/src/main/res/values-tr/strings.xml | 7 +- app/src/main/res/values-uk/strings.xml | 7 +- app/src/main/res/values-zh-rCN/strings.xml | 7 +- app/src/main/res/values-zh-rTW/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 22 files changed, 372 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index e9577eec20..74350e8b00 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1968,11 +1968,22 @@ object PrefManager { get() = getPref(USAGE_ANALYTICS_ENABLED, true) set(value) { setPref(USAGE_ANALYTICS_ENABLED, value) } - // FEX code caches: share which code FEX translated and compile it into caches on the device (FexCodeMaps, FexCodeCaches). - private val FEX_CODE_CACHE_ENABLED = booleanPreferencesKey("fex_code_cache_enabled") - var fexCodeCacheEnabled: Boolean - get() = getPref(FEX_CODE_CACHE_ENABLED, true) - set(value) { setPref(FEX_CODE_CACHE_ENABLED, value) } + // FEX code maps: record which code FEX translated and upload it (FexCodeMaps). The key is the one of the + // earlier single FEX code cache switch, so a player who turned that off stays off. + private val FEX_CODE_MAP_UPLOAD_ENABLED = booleanPreferencesKey("fex_code_cache_enabled") + var fexCodeMapUploadEnabled: Boolean + get() = getPref(FEX_CODE_MAP_UPLOAD_ENABLED, true) + set(value) { + setPref(FEX_CODE_MAP_UPLOAD_ENABLED, value) + if (!value) setPref(FEX_CODE_CACHE_USE_ENABLED, false) + } + + // FEX code caches: compile the recorded maps into caches on the device and load them (FexCodeCaches). + // Needs the maps, so it is off whenever the upload is. + private val FEX_CODE_CACHE_USE_ENABLED = booleanPreferencesKey("fex_code_cache_use_enabled") + var fexCodeCacheUseEnabled: Boolean + get() = fexCodeMapUploadEnabled && getPref(FEX_CODE_CACHE_USE_ENABLED, false) + set(value) { setPref(FEX_CODE_CACHE_USE_ENABLED, value && fexCodeMapUploadEnabled) } private val NEXUS_LAST_PLACEMENT_JSON = stringPreferencesKey("nexus_last_placement_json") var nexusLastPlacementJson: String diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt index 7c03f27848..324773159b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt @@ -102,15 +102,28 @@ fun SettingsGroupEmulation() { PrefManager.autoApplyKnownConfig = it }, ) - var fexCodeCacheEnabled by rememberSaveable { mutableStateOf(PrefManager.fexCodeCacheEnabled) } + var fexCodeMapUploadEnabled by rememberSaveable { mutableStateOf(PrefManager.fexCodeMapUploadEnabled) } + var fexCodeCacheUseEnabled by rememberSaveable { mutableStateOf(PrefManager.fexCodeCacheUseEnabled) } SettingsSwitch( colors = settingsTileColorsAlt(), - state = fexCodeCacheEnabled, - title = { Text(text = stringResource(R.string.settings_emulation_fex_code_cache_title)) }, - subtitle = { Text(text = stringResource(R.string.settings_emulation_fex_code_cache_subtitle)) }, + state = fexCodeMapUploadEnabled, + title = { Text(text = stringResource(R.string.settings_emulation_fex_code_map_upload_title)) }, + subtitle = { Text(text = stringResource(R.string.settings_emulation_fex_code_map_upload_subtitle)) }, onCheckedChange = { - fexCodeCacheEnabled = it - PrefManager.fexCodeCacheEnabled = it + fexCodeMapUploadEnabled = it + PrefManager.fexCodeMapUploadEnabled = it + fexCodeCacheUseEnabled = PrefManager.fexCodeCacheUseEnabled + }, + ) + SettingsSwitch( + colors = settingsTileColorsAlt(), + enabled = fexCodeMapUploadEnabled, + state = fexCodeCacheUseEnabled, + title = { Text(text = stringResource(R.string.settings_emulation_fex_code_cache_use_title)) }, + subtitle = { Text(text = stringResource(R.string.settings_emulation_fex_code_cache_use_subtitle)) }, + onCheckedChange = { + PrefManager.fexCodeCacheUseEnabled = it + fexCodeCacheUseEnabled = PrefManager.fexCodeCacheUseEnabled }, ) SettingsMenuLink( diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 72e20770c1..b0dfbcb2c1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -157,6 +157,7 @@ import app.gamenative.utils.GuestOutputTail import app.gamenative.utils.DebugRunParams import app.gamenative.utils.DebugRunParamsHolder import app.gamenative.utils.ExecutableSelectionUtils +import app.gamenative.utils.FexCodeCaches import app.gamenative.utils.FexCodeMaps import app.gamenative.utils.LsfgQuickMenuHelper import app.gamenative.utils.LsfgVkManager @@ -4608,6 +4609,10 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setFEXCorePreset(container.fexCorePreset) } guestProgramLauncherComponent.setPreUnpack { + // First: the launcher has just stopped every Wine process, and the steps below may start Wine again. + if (guestProgramLauncherComponent is BionicProgramLauncherComponent && !bootToContainer && !testGraphics) { + FexCodeCaches.buildBeforeLaunch(context, guestProgramLauncherComponent) { launchCancel.get() } + } unpackExecutableFile( context = context, needsUnpacking = container.isNeedsUnpacking, diff --git a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt index 3a80309dbd..3014dffe9e 100644 --- a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt +++ b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt @@ -5,6 +5,8 @@ import android.os.SystemClock import android.system.Os import app.gamenative.PluviaApp import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.events.AndroidEvent import com.winlator.container.Container import com.winlator.container.ContainerOverlay import com.winlator.contents.ContentsManager @@ -29,15 +31,18 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.security.MessageDigest import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean /** * FEX code caches compiled on the device from its own code maps (FexCodeMaps). After a session the - * maps FEX wrote are hard-linked into an inbox before the upload deletes them, then a background job - * merges them into one cumulative map per image (a session that loaded a cache records only the - * misses, so the union is what the cache must cover) and runs the pack's FEXOfflineCompiler under the - * session's own launcher environment, through `wine cmd /c` on a batch file in the prefix. Each image is - * recompiled only when its cumulative map grew; a new FEX build or FEX setting deletes every cache, - * because FEX does not check that a cache matches the build that loads it. + * maps FEX wrote are hard-linked into an inbox before the upload deletes them. The inbox is merged into + * one cumulative map per image (a session that loaded a cache records only the misses, so the union is + * what the cache must cover) and the pack's FEXOfflineCompiler runs under the session's own launcher + * environment, through `wine cmd /c` on a batch file in the prefix. That build runs in two places: in + * the background right after a session, and on the loading screen before the next guest starts, which + * covers whatever the first one did not finish (the app closed, a launch stopped it). Each image is + * recompiled only when its cumulative map grew by [REBUILD_GROWTH_PERCENT]; a new FEX build or FEX + * setting deletes every cache, because FEX does not check that a cache matches the build that loads it. */ object FexCodeCaches { @@ -60,6 +65,9 @@ object FexCodeCaches { // One large game's exe is about 240 MB of cache; this keeps it plus its own DLLs and the Steam // client's. Every cache can be rebuilt from the maps, so eviction only costs compile time. private const val STORAGE_CAP_BYTES = 512L * 1024 * 1024 + + // A cache is rebuilt once its image recorded this many percent more blocks than it was built from. + private const val REBUILD_GROWTH_PERCENT = 10 private const val MAX_INBOX_FILES = 20 private const val MAX_MAP_BYTES = 8L * 1024 * 1024 private const val STALE_IMAGE_MS = 60L * 24 * 60 * 60 * 1000 @@ -76,6 +84,7 @@ object FexCodeCaches { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lock = Any() private var job: Job? = null + private var jobBeforeLaunch = false @Volatile private var stopReason: String? = null @@ -83,6 +92,16 @@ object FexCodeCaches { @Volatile private var sessionLauncher: BionicProgramLauncherComponent? = null + // The launcher of the starting session when its container records code maps. + @Volatile + private var captureLauncher: BionicProgramLauncherComponent? = null + + private class Mode(val beforeLaunch: Boolean, val stopRequested: () -> String?, val onProgress: (done: Int, total: Int) -> Unit) + + private class Outcome(val images: Int, val produced: Int, val ended: String?) + + private val afterSessionMode = Mode(false, { stopReason }, { _, _ -> }) + private fun fexDir(container: Container) = File(container.rootDir, FEX_DIR) private fun cacheDir(container: Container) = File(fexDir(container), "cache") private fun workDir(container: Container) = File(fexDir(container), "gamenative") @@ -92,19 +111,38 @@ object FexCodeCaches { private fun logsDir(container: Container) = File(workDir(container), "logs") /** - * Called by the launcher right before a guest starts: stops a running compile, remembers the - * launcher for the compile after this session, and deletes caches built for another FEX build or - * other FEX settings so this launch never loads them. Never throws. + * Called by the launcher when it starts, before the pre-launch steps: stops a compile still + * running from the last session, remembers the launcher for the builds before and after this + * session, and deletes caches this launch must not load: those built for another FEX build or + * other FEX settings, or all of them while code caches are off. Never throws. */ @JvmStatic fun onGuestStarting(context: Context, contentsManager: ContentsManager, launcher: BionicProgramLauncherComponent) { try { stopForLaunch("a game is starting") sessionLauncher = launcher + captureLauncher = null val container = launcher.container ?: return - if (!FexCodeMaps.captureEnabled(context, contentsManager, container)) return + if (!FexCodeMaps.supportsCodeMaps(context, contentsManager, container)) return val state = readState(container) - if (invalidateIfBuildChanged(container, state, buildId(context, container, launcher))) writeState(container, state) + val changed = if (PrefManager.fexCodeCacheUseEnabled) { + invalidateIfBuildChanged(container, state, buildId(context, container, launcher)) + } else { + deleteCachesWhileOff(container, state) + } + if (changed) writeState(container, state) + if (PrefManager.fexCodeMapUploadEnabled) captureLauncher = launcher + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Code cache check before launch failed") + } + } + + /** Called by the launcher right before the guest process starts: logs the caches this launch loads. Never throws. */ + @JvmStatic + fun onGuestReady(launcher: BionicProgramLauncherComponent) { + try { + if (launcher !== captureLauncher) return + val container = launcher.container ?: return val caches = cacheDir(container).listFiles { f -> f.isFile }.orEmpty() if (caches.isEmpty()) { Timber.tag(TAG).i("Launch with no code caches for ${container.id}") @@ -115,15 +153,18 @@ object FexCodeCaches { ) } } catch (e: Exception) { - Timber.tag(TAG).w(e, "Code cache check before launch failed") + Timber.tag(TAG).w(e, "Code cache listing before launch failed") } } - /** Stops a running compile and waits for it for at most [STOP_WAIT_MS]. Never throws. */ + /** + * Stops a compile left running from the last session and waits for it for at most [STOP_WAIT_MS]; + * its unfinished images stay pending for the build before this launch. Never throws. + */ @JvmStatic fun stopForLaunch(reason: String) { try { - val running = synchronized(lock) { job?.takeIf { it.isActive } } ?: return + val running = synchronized(lock) { job?.takeIf { it.isActive && !jobBeforeLaunch } } ?: return stopReason = reason Timber.tag(TAG).i("Stopping the code cache compile: $reason") val ended = runBlocking { withTimeoutOrNull(STOP_WAIT_MS) { running.join() } } @@ -142,7 +183,7 @@ object FexCodeCaches { sessionLauncher = null try { if (!FexCodeMaps.captureEnabled(context, container)) { - Timber.tag(TAG).i("Skip compile for ${container.id}: FEX code caches are off or the FEX pack has no offline compiler") + Timber.tag(TAG).i("Skip compile for ${container.id}: FEX code map recording is off or the FEX pack has no offline compiler") return } linkSessionMaps(container) @@ -152,6 +193,7 @@ object FexCodeCaches { return } stopReason = null + jobBeforeLaunch = false job = scope.launch { try { compileAfterSession(context, container, launcher) @@ -196,11 +238,112 @@ object FexCodeCaches { private suspend fun compileAfterSession(context: Context, container: Container, launcher: BionicProgramLauncherComponent?) { val state = readState(container) + mergeAndPrune(container, state) + if (!PrefManager.fexCodeCacheUseEnabled) return skip(container, "FEX code caches are off; the maps are kept for a later build") + if (launcher == null) return skip(container, "no launcher from this session") + compile(context, container, launcher, state, afterSessionMode) + } + + /** + * Pre-launch step: merges the inbox and compiles every block no cache covers yet before the guest + * starts, so this launch already loads the result. Blocking; shows its progress on the booting + * splash and offers Skip. Skip or [isCancelled] stops the compile at once; finished caches are kept + * and the rest stays pending. Decides that there is nothing to do from the state file and directory + * listings alone. Never throws. + */ + @JvmStatic + fun buildBeforeLaunch(context: Context, launcher: BionicProgramLauncherComponent, isCancelled: () -> Boolean) { + if (launcher !== captureLauncher || !PrefManager.fexCodeCacheUseEnabled) return + val container = launcher.container ?: return + val skipped = AtomicBoolean(false) + val started = SystemClock.elapsedRealtime() + try { + val inbox = inboxDir(container).listFiles { f -> f.isFile && f.name.endsWith(".bin") }.orEmpty() + if (inbox.isEmpty()) { + val selection = selectImages(container, readState(container), log = false) + if (withCompiler(container, selection.images, log = false).isEmpty()) return nothingToBuild(container, selection) + } + val mode = Mode( + beforeLaunch = true, + stopRequested = { + when { + skipped.get() -> "skipped by the player" + isCancelled() -> "the launch was cancelled" + else -> null + } + }, + onProgress = { done, total -> showProgress(context, (done + 1).coerceAtMost(total), total) }, + ) + val work = synchronized(lock) { + if (job?.isActive == true) return@synchronized null + jobBeforeLaunch = true + scope.launch { + try { + buildForLaunch(context, container, launcher, mode, skipped, started) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Pre-launch build for ${container.id} failed") + } + }.also { job = it } + } + if (work == null) { + Timber.tag(TAG).i("Skip pre-launch build for ${container.id}: the compile from the last session is still running") + return + } + LaunchSkip.offer { skipped.set(true) } + runBlocking { work.join() } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Pre-launch build for ${container.id} failed") + } finally { + LaunchSkip.withdraw() + } + } + + private suspend fun buildForLaunch( + context: Context, + container: Container, + launcher: BionicProgramLauncherComponent, + mode: Mode, + skipped: AtomicBoolean, + started: Long, + ) { + val state = readState(container) + mergeAndPrune(container, state) + val selection = selectImages(container, state, log = false) + val pending = withCompiler(container, selection.images, log = false) + if (pending.isEmpty()) return nothingToBuild(container, selection) + val newBlocks = pending.sumOf { (_, entry) -> + val built = entry.optInt("builtBlocks", 0).takeIf { File(cacheDir(container), entry.optString("cacheName")).isFile } ?: 0 + (entry.optInt("blocks") - built).coerceAtLeast(0) + } + Timber.tag(TAG).i("Pre-launch build for ${container.id}: ${pending.size} images, $newBlocks new blocks") + showProgress(context, 1, pending.size) + val outcome = compile(context, container, launcher, state, mode) + val seconds = (SystemClock.elapsedRealtime() - started) / 1000.0 + val done = outcome?.produced ?: 0 + val total = outcome?.images ?: pending.size + when { + outcome != null && outcome.ended == null -> Timber.tag(TAG).i("Pre-launch build finished in %.1f s".format(Locale.ROOT, seconds)) + skipped.get() -> Timber.tag(TAG).i("Pre-launch build skipped by the player after %.1f s: %d of %d images done".format(Locale.ROOT, seconds, done, total)) + else -> Timber.tag(TAG).i( + "Pre-launch build ended early after %.1f s (%s): %d of %d images done" + .format(Locale.ROOT, seconds, outcome?.ended ?: mode.stopRequested() ?: "not started", done, total), + ) + } + } + + private fun nothingToBuild(container: Container, selection: Selection) { + val waiting = if (selection.waitingBlocks > 0) " (${selection.waitingBlocks} new blocks below the rebuild threshold)" else "" + Timber.tag(TAG).i("Nothing to build for ${container.id}$waiting") + } + + private fun showProgress(context: Context, current: Int, total: Int) { + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.fex_code_cache_building, current, total))) + } + + private fun mergeAndPrune(container: Container, state: JSONObject) { mergeInbox(container, state) pruneStaleImages(container, state) writeState(container, state) - if (launcher == null) return skip(container, "no launcher from this session") - compile(context, container, launcher, state) } private fun skip(container: Container, reason: String) { @@ -360,26 +503,30 @@ object FexCodeCaches { } } - private suspend fun compile(context: Context, container: Container, launcher: BionicProgramLauncherComponent, state: JSONObject) { - stopReason?.let { return skip(container, it) } - if (!PrefManager.fexCodeCacheEnabled) return skip(container, "FEX code caches were turned off") - if (PluviaApp.xEnvironment != null) return skip(container, "a game session is active") - if (!waitForWineToExit(container)) return - stopReason?.let { return skip(container, it) } + private suspend fun compile( + context: Context, + container: Container, + launcher: BionicProgramLauncherComponent, + state: JSONObject, + mode: Mode, + ): Outcome? { + fun skipped(reason: String): Outcome? { + skip(container, reason) + return null + } + mode.stopRequested()?.let { return skipped(it) } + if (!PrefManager.fexCodeCacheUseEnabled) return skipped("FEX code caches are off") + if (!mode.beforeLaunch && PluviaApp.xEnvironment != null) return skipped("a game session is active") + if (!waitForWineToExit(container, mode.stopRequested)) return null + mode.stopRequested()?.let { return skipped(it) } val (build, config) = buildOf(context, container, launcher) invalidateIfBuildChanged(container, state, build) HOSTED_LEFTOVERS.forEach { File(container.rootDir, it).delete() } - val system32 = File(container.rootDir, ".wine/drive_c/windows/system32") - val images = selectImages(container, state).filter { (key, entry) -> - val bits = entry.optInt("bits") - val ok = File(system32, "FEXOfflineCompiler$bits.exe").isFile - if (!ok) Timber.tag(TAG).i("Skip ${entry.optString("name")} ($key): no FEXOfflineCompiler$bits.exe in system32") - ok - } + val images = withCompiler(container, selectImages(container, state).images, log = true) writeState(container, state) - if (images.isEmpty()) return skip(container, "nothing new to compile") + if (images.isEmpty()) return skipped("nothing new to compile") val work = workDir(container) val staging = stagingDir(container) @@ -423,14 +570,23 @@ object FexCodeCaches { val unset = arrayOf("FEX_ENABLECODECACHINGWIP") val run = scope.async { launcher.execShellCommand("wine cmd /c $WIN_WORK_DIR\\compile.bat", true, env, unset) } var ended: String? = null + var shown = 0 while (run.isActive) { - stopReason?.let { ended = "stopped: $it" } + mode.stopRequested()?.let { ended = "stopped: $it" } if (ended == null && SystemClock.elapsedRealtime() - started > COMPILE_TIMEOUT_MS) ended = "hit the ${COMPILE_TIMEOUT_MS / 60_000}-minute limit" if (ended != null) break + if (mode.beforeLaunch) { + val done = logs.list().orEmpty().count { it.endsWith(".exit.txt") } + if (done != shown && done < images.size) { + shown = done + mode.onProgress(done, images.size) + } + } withTimeoutOrNull(POLL_MS) { run.join() } } - ended?.let { reason -> - Timber.tag(TAG).w("Compile for ${container.id} $reason; killing its Wine processes") + ended?.let { reason -> Timber.tag(TAG).w("Compile for ${container.id} $reason; killing its Wine processes") } + // Before a launch the compile's wineserver must not linger: the guest would attach to it. + if (ended != null || mode.beforeLaunch) { runCatching { launcher.execShellCommand("wineserver -k", true, env, unset) } .onFailure { Timber.tag(TAG).w(it, "wineserver -k after the compile failed") } withTimeoutOrNull(STOP_WAIT_MS) { run.join() } @@ -481,14 +637,15 @@ object FexCodeCaches { "Compile finished for ${container.id} in %.1f s%s: %d caches written (%s), %d failed; container caches now %s" .format(Locale.ROOT, (SystemClock.elapsedRealtime() - started) / 1000.0, ended?.let { " ($it)" }.orEmpty(), produced, mb(producedBytes), failed, mb(total)), ) + return Outcome(images.size, produced, ended) } - private suspend fun waitForWineToExit(container: Container): Boolean { + private suspend fun waitForWineToExit(container: Container, stopRequested: () -> String?): Boolean { val deadline = SystemClock.elapsedRealtime() + WINE_EXIT_WAIT_MS while (true) { val running = ProcessHelper.listRunningWineProcesses() if (running.isEmpty()) return true - if (stopReason != null || SystemClock.elapsedRealtime() > deadline) { + if (stopRequested() != null || SystemClock.elapsedRealtime() > deadline) { skip(container, "Wine processes still running: ${running.joinToString()}") return false } @@ -496,34 +653,57 @@ object FexCodeCaches { } } - /** Images whose cumulative map has blocks no cache covers yet, most blocks first. */ - private fun selectImages(container: Container, state: JSONObject): List> { + /** [images] whose bitness has an offline compiler in the prefix. */ + private fun withCompiler(container: Container, images: List>, log: Boolean): List> { + val system32 = File(container.rootDir, ".wine/drive_c/windows/system32") + return images.filter { (key, entry) -> + val bits = entry.optInt("bits") + val ok = File(system32, "FEXOfflineCompiler$bits.exe").isFile + if (!ok && log) Timber.tag(TAG).i("Skip ${entry.optString("name")} ($key): no FEXOfflineCompiler$bits.exe in system32") + ok + } + } + + /** Images to build, most blocks first, and the recorded blocks that wait below the rebuild threshold. */ + private class Selection(val images: List>, val waitingBlocks: Int) + + /** + * Images with no cache, or whose cumulative map grew by [REBUILD_GROWTH_PERCENT] of the blocks their + * cache was built from. A compile rebuilds the whole cache, so a few misses wait for the next rebuild. + */ + private fun selectImages(container: Container, state: JSONObject, log: Boolean = true): Selection { val entries = imagesOf(state) val selected = mutableListOf>() var upToDate = 0 + var waiting = 0 for (key in entries.keys()) { val entry = entries.getJSONObject(key) val name = entry.optString("name") val blocks = entry.optInt("blocks") val cacheName = entry.optString("cacheName") if (blocks == 0 || !File(mapsDir(container), "$key.bin").isFile) continue - if (entry.optInt("builtBlocks", -1) == blocks && cacheName.isNotEmpty() && File(cacheDir(container), cacheName).isFile) { - upToDate++ - continue + val built = entry.optInt("builtBlocks", -1) + if (built > 0 && cacheName.isNotEmpty() && File(cacheDir(container), cacheName).isFile) { + val added = (blocks - built).coerceAtLeast(0) + if (added * 100L < built * REBUILD_GROWTH_PERCENT.toLong()) { + upToDate++ + waiting += added + continue + } } if (entry.optInt("failedBlocks", -1) == blocks) { - Timber.tag(TAG).i("Skip $name ($key): its compile failed at $blocks blocks and the map has not grown") + if (log) Timber.tag(TAG).i("Skip $name ($key): its compile failed at $blocks blocks and the map has not grown") continue } val evictedAt = entry.optInt("evictedAtBlocks") if (evictedAt > 0 && blocks < evictedAt * 2) { - Timber.tag(TAG).i("Skip $name ($key): evicted for the storage cap at $evictedAt blocks; waiting for ${evictedAt * 2}") + if (log) Timber.tag(TAG).i("Skip $name ($key): evicted for the storage cap at $evictedAt blocks; waiting for ${evictedAt * 2}") continue } selected += key to entry } - if (upToDate > 0) Timber.tag(TAG).i("$upToDate images already have a cache for every recorded block") - return selected.sortedByDescending { it.second.optInt("blocks") } + if (upToDate > 0 && log) Timber.tag(TAG).i("$upToDate images have a current cache; $waiting new blocks below the rebuild threshold") + return Selection(selected.sortedByDescending { it.second.optInt("blocks") }, waiting) } /** Deletes caches until the container's total is under [STORAGE_CAP_BYTES]; returns the total left. */ @@ -595,6 +775,28 @@ object FexCodeCaches { return true } + /** + * While code caches are off, deletes every cache in the prefix and the marks of what was built: + * the FEX switch that records the maps also loads caches. The maps and the upload ledger stay. + * Returns whether [state] changed. + */ + private fun deleteCachesWhileOff(container: Container, state: JSONObject): Boolean { + val caches = cacheDir(container).listFiles { f -> f.isFile }.orEmpty() + val entries = state.optJSONObject("images") + val marked = entries?.keys()?.asSequence()?.map { entries.getJSONObject(it) } + ?.filter { it.has("builtBlocks") || it.has("cacheName") || it.has("cacheSize") }?.toList().orEmpty() + if (caches.isEmpty() && marked.isEmpty()) return false + val bytes = caches.sumOf { it.length() } + val deleted = caches.count { it.delete() } + marked.forEach { entry -> + entry.remove("builtBlocks") + entry.remove("cacheName") + entry.remove("cacheSize") + } + Timber.tag(TAG).i("Code caches are off: deleted $deleted caches (${mb(bytes)}) for ${container.id}") + return marked.isNotEmpty() + } + private fun imagesOf(state: JSONObject): JSONObject = state.optJSONObject("images") ?: JSONObject().also { state.put("images", it) } diff --git a/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt b/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt index e31ab4cff6..e8e43314d9 100644 --- a/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt +++ b/app/src/main/java/app/gamenative/utils/FexCodeMaps.kt @@ -63,14 +63,14 @@ object FexCodeMaps { return packSupportsCodeCaches(ContentsManager.getInstallDir(context, profile)) } - /** Whether this launch should have FEX write code maps: the user has the feature on and the pack supports it. */ + /** Whether this launch should have FEX write code maps: the user has the upload on and the pack supports it. */ @JvmStatic fun captureEnabled(context: Context, container: Container): Boolean = - PrefManager.fexCodeCacheEnabled && supportsCodeMaps(context, container) + PrefManager.fexCodeMapUploadEnabled && supportsCodeMaps(context, container) @JvmStatic fun captureEnabled(context: Context, contentsManager: ContentsManager, container: Container): Boolean = - PrefManager.fexCodeCacheEnabled && supportsCodeMaps(context, contentsManager, container) + PrefManager.fexCodeMapUploadEnabled && supportsCodeMaps(context, contentsManager, container) fun codeMapDir(container: Container): File = File(container.rootDir, CODEMAP_DIR) @@ -145,7 +145,7 @@ object FexCodeMaps { val meta = sessionMeta(context, container, containerId, sessionLengthSec) for (file in files.take(MAX_FILES_PER_SESSION)) { // The user may turn the feature off while a batch runs; stop before the next request. - if (!PrefManager.fexCodeCacheEnabled) return + if (!PrefManager.fexCodeMapUploadEnabled) return var delta: File? = null var gz: File? = null try { diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 076bd6fdc5..f960ce3dcf 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -157,6 +157,7 @@ public void start() { extractBox64Files(); if (preUnpack != null) preUnpack.run(); if (launchCancelled != null && launchCancelled.getAsBoolean()) throw new IllegalStateException("Launch cancelled before the guest started"); + FexCodeCaches.onGuestReady(this); pid = execGuestProgram(); Log.d("BionicProgramLauncherComponent", "Process " + pid + " started"); SteamService.setKeepAlive(true); diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 20beeff9bf..8d3aaa5026 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -859,6 +859,7 @@ Del de shadere, der optages mens du spiller, med andre spillere. Slås dette fra, hentes de delte caches, der mindsker hakken, heller ikke Forbereder shadere… Forbereder shadere %1$d%% + Bygger kodecache… (%1$d/%2$d) Emulering @@ -1011,8 +1012,10 @@ Standard containerkonfiguration Anvend kendt konfiguration automatisk Anvend automatisk anbefalede indstillinger for Steam-spil ved første start - FEX-kodecacher - Del hvilken kode FEX oversatte, og forbered oversættelserne på enheden efter du har spillet, for hurtigere start. Cacher bruger lagerplads i spillets præfiks. + Upload FEX-kodekort + Sender hvilke kodeblokke i et spils programfiler der blev kørt, ingen personlige data. Under 1 MB pr. spil. + Brug FEX-kodecaches (eksperimentel) + Kan gøre opstart hurtigere og reducere CPU-forbruget, kan bruge et par hundrede MB pr. spil og tilføjer et ekstra trin, når spil startes Box64-forudindstillinger Vis, redigér og opret Box64-forudindstillinger Driverhåndtering diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0f7175f9db..3c2523721b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1003,6 +1003,7 @@ Beim Spielen aufgezeichnete Shader mit anderen Spielern teilen. Ist dies aus, werden auch die geteilten Caches, die Ruckler verringern, nicht heruntergeladen Shader werden vorbereitet… Shader werden vorbereitet %1$d%% + Code-Cache wird erstellt… (%1$d/%2$d) Emulation @@ -1155,8 +1156,10 @@ Standard-Container-Konfiguration Bekannte Konfiguration automatisch anwenden Beim ersten Start automatisch empfohlene Einstellungen für Steam-Spiele anwenden - FEX-Code-Caches - Teile, welchen Code FEX übersetzt hat, und bereite diese Übersetzungen nach dem Spielen auf dem Gerät vor, für schnellere Starts. Caches belegen Speicherplatz im Präfix des Spiels. + FEX-Code-Maps hochladen + Sendet, welche Codeblöcke der ausführbaren Dateien eines Spiels liefen, keine persönlichen Daten. Weniger als 1 MB pro Spiel. + FEX-Code-Caches verwenden (experimentell) + Kann Starts beschleunigen und die CPU-Last senken, kann einige hundert MB pro Spiel belegen und fügt beim Starten von Spielen einen zusätzlichen Schritt hinzu Box64-Voreinstellungen Voreinstellungen anzeigen, ändern oder neu erstellen Treiberverwaltung diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b7ddf11b76..73b7712d40 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1051,6 +1051,7 @@ Comparte con otros jugadores los shaders registrados mientras juegas. Al desactivarlo tampoco se descargan las cachés compartidas que reducen los tirones Preparando shaders… Preparando shaders %1$d%% + Creando caché de código… (%1$d/%2$d) Emulación @@ -1203,8 +1204,10 @@ Configuración por defecto del contenedor Aplicar configuración conocida automáticamente Aplica automáticamente la configuración recomendada para juegos de Steam al iniciarlos por primera vez. - Cachés de código FEX - Comparte qué código tradujo FEX y prepara esas traducciones en el dispositivo después de jugar para arrancar más rápido. Las cachés ocupan espacio en el prefijo del juego. + Subir mapas de código de FEX + Envía qué bloques de código de los ejecutables de un juego se ejecutaron, sin datos personales. Menos de 1 MB por juego. + Usar cachés de código de FEX (experimental) + Puede acelerar los inicios y reducir el uso de CPU, puede ocupar unos cientos de MB por juego y añade un paso adicional al iniciar juegos Preajustes de Box64 Ve, modifica y crea preajustes de Box64. Gestor de controladores diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15a6f2ec7b..a0c240c548 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1061,6 +1061,7 @@ Partager avec les autres joueurs les shaders enregistrés pendant que vous jouez. Désactiver cette option empêche aussi le téléchargement des caches partagés qui réduisent les saccades Préparation des shaders… Préparation des shaders %1$d%% + Création du cache de code… (%1$d/%2$d) Émulation @@ -1213,8 +1214,10 @@ Configuration de conteneur par défaut Appliquer automatiquement la configuration connue Appliquer automatiquement les paramètres recommandés pour les jeux Steam au premier lancement - Caches de code FEX - Partage le code traduit par FEX et prépare ces traductions sur l\'appareil après chaque partie, pour des lancements plus rapides. Les caches occupent de l\'espace dans le préfixe du jeu. + Envoyer les cartes de code FEX + Envoie les blocs de code des exécutables d\'un jeu qui ont été exécutés, sans données personnelles. Moins de 1 Mo par jeu. + Utiliser les caches de code FEX (expérimental) + Peut accélérer les lancements et réduire l\'utilisation du processeur, peut occuper quelques centaines de Mo par jeu et ajoute une étape supplémentaire au lancement des jeux Préréglages Box64 Voir, modifier et créer des préréglages Box64 Gestionnaire de pilotes diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 85867d5972..47bee48f2c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1035,6 +1035,7 @@ Condividi con gli altri giocatori gli shader registrati mentre giochi. Disattivandolo non vengono nemmeno scaricate le cache condivise che riducono gli scatti Preparazione degli shader… Preparazione degli shader %1$d%% + Creazione della cache del codice… (%1$d/%2$d) Emulazione @@ -1187,8 +1188,10 @@ Config Container Predefinita Applica automaticamente config nota Applica automaticamente le impostazioni consigliate per i giochi Steam al primo avvio - Cache di codice FEX - Condividi quale codice FEX ha tradotto e prepara quelle traduzioni sul dispositivo dopo aver giocato, per avvii più rapidi. Le cache occupano spazio nel prefisso del gioco. + Carica le mappe di codice FEX + Invia quali blocchi di codice degli eseguibili di un gioco sono stati eseguiti, nessun dato personale. Meno di 1 MB per gioco. + Usa le cache di codice FEX (sperimentale) + Può velocizzare gli avvii e ridurre l\'uso della CPU, può occupare qualche centinaio di MB per gioco e aggiunge un passaggio in più all\'avvio dei giochi Preset Box64 Visualizza, modifica e crea preset Box64 Gestione Driver diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f37e1e2061..73439cdcd7 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1208,6 +1208,7 @@ プレイ中に記録したシェーダーを他のプレイヤーと共有します。オフにすると、カクつきを減らす共有キャッシュのダウンロードも行われません シェーダーを準備中… シェーダーを準備中 %1$d%% + コードキャッシュを構築中… (%1$d/%2$d) エミュレーション @@ -1358,8 +1359,10 @@ デフォルトのコンテナ構成 既知の構成を自動適用する 初回起動時に Steam ゲームの推奨設定を自動的に適用する - FEX コードキャッシュ - FEX が変換したコードを共有し、プレイ後にその変換をこの端末で用意して起動を速くします。キャッシュはゲームのプレフィックスの容量を使います。 + FEX コードマップをアップロード + ゲームの実行ファイルのどのコードブロックが実行されたかを送信します。個人データは含まれません。1 ゲームあたり 1 MB 未満。 + FEX コードキャッシュを使用 (実験的) + 起動を高速化し CPU 使用率を下げられる場合がありますが、1 ゲームあたり数百 MB を使用することがあり、ゲーム起動時に追加の手順が入ります Box64 プリセット Box64 プリセットの表示、変更、作成 ドライバーマネージャー diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ab89aa8d14..307d4d6edd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1044,6 +1044,7 @@ 플레이 중 기록된 셰이더를 다른 플레이어와 공유합니다. 끄면 끊김을 줄여 주는 공유 캐시도 다운로드하지 않습니다 셰이더 준비 중… 셰이더 준비 중 %1$d%% + 코드 캐시 생성 중… (%1$d/%2$d) 에뮬레이션 @@ -2236,8 +2237,10 @@ 렌더러 프레젠트 모드 알려진 설정 자동 적용 Steam 게임을 처음 실행할 때 권장 설정을 자동으로 적용합니다 - FEX 코드 캐시 - FEX가 변환한 코드를 공유하고, 플레이 후 이 기기에서 변환을 미리 준비해 더 빠르게 실행합니다. 캐시는 게임 프리픽스의 저장 공간을 사용합니다. + FEX 코드 맵 업로드 + 게임 실행 파일에서 실행된 코드 블록 정보를 보냅니다. 개인 데이터는 포함되지 않습니다. 게임당 1MB 미만. + FEX 코드 캐시 사용 (실험적) + 실행 속도를 높이고 CPU 사용량을 줄일 수 있지만, 게임당 수백 MB를 사용할 수 있으며 게임 실행 시 추가 단계가 생깁니다 Wi-Fi 또는 LAN에 연결되어 있지 않습니다 다운로드 일시 중지됨 - Wi-Fi 또는 LAN 연결 대기 중 %s 다운로드 중 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 367aa8bd2d..4c5c933def 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1052,6 +1052,7 @@ Udostępniaj innym graczom shadery zapisane podczas gry. Wyłączenie tej opcji zatrzymuje też pobieranie współdzielonej pamięci podręcznej ograniczającej przycięcia Przygotowywanie shaderów… Przygotowywanie shaderów %1$d%% + Tworzenie pamięci podręcznej kodu… (%1$d/%2$d) Emulacja @@ -1206,8 +1207,10 @@ Domyślna konfiguracja kontenera Automatycznie zastosuj znaną konfigurację Automatycznie zastosuj zalecane ustawienia dla gier Steam przy pierwszym uruchomieniu - Pamięci podręczne kodu FEX - Udostępniaj kod przetłumaczony przez FEX i przygotowuj te tłumaczenia na urządzeniu po grze, dla szybszego uruchamiania. Pamięci podręczne zajmują miejsce w prefiksie gry. + Wysyłaj mapy kodu FEX + Wysyła informacje, które bloki kodu plików wykonywalnych gry zostały uruchomione, bez danych osobowych. Mniej niż 1 MB na grę. + Używaj pamięci podręcznej kodu FEX (eksperymentalne) + Może przyspieszyć uruchamianie i zmniejszyć użycie procesora, może zajmować kilkaset MB na grę i dodaje dodatkowy krok przy uruchamianiu gier Presety Box64 Przeglądaj, modyfikuj i twórz presety Box64 Menedżer sterowników diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 115e114a8f..3f30060bdd 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -859,6 +859,7 @@ Compartilha com outros jogadores os shaders gravados enquanto você joga. Desativar também impede o download dos caches compartilhados que reduzem travamentos Preparando shaders… Preparando shaders %1$d%% + Criando cache de código… (%1$d/%2$d) Emulação @@ -1011,8 +1012,10 @@ Configuração padrão do contêiner Aplicar configuração conhecida automaticamente Aplica automaticamente as configurações recomendadas para jogos Steam na primeira inicialização - Caches de código FEX - Compartilhe qual código o FEX traduziu e prepare essas traduções no dispositivo depois de jogar, para inicializações mais rápidas. Os caches ocupam espaço no prefixo do jogo. + Enviar mapas de código do FEX + Envia quais blocos de código dos executáveis de um jogo foram executados, sem dados pessoais. Menos de 1 MB por jogo. + Usar caches de código do FEX (experimental) + Pode acelerar as inicializações e reduzir o uso da CPU, pode ocupar algumas centenas de MB por jogo e adiciona uma etapa extra ao iniciar jogos Predefinições do Box64 Visualizar, modificar e criar predefinições do Box64 Gerenciador de drivers diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a1743d0274..b239e424b3 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1044,6 +1044,7 @@ Partajează cu alți jucători shaderele înregistrate în timp ce joci. Dezactivarea oprește și descărcarea cache-urilor partajate care reduc sacadările Se pregătesc shaderele… Se pregătesc shaderele %1$d%% + Se construiește memoria cache de cod… (%1$d/%2$d) Emulare @@ -1197,8 +1198,10 @@ Config container implicit Aplică automat configurația cunoscută Aplică automat setările recomandate pentru jocurile Steam la prima lansare - Cache-uri de cod FEX - Partajează ce cod a tradus FEX și pregătește aceste traduceri pe dispozitiv după joc, pentru porniri mai rapide. Cache-urile ocupă spațiu în prefixul jocului. + Încarcă hărțile de cod FEX + Trimite ce blocuri de cod din executabilele unui joc au rulat, fără date personale. Sub 1 MB per joc. + Folosește memoria cache de cod FEX (experimental) + Poate accelera pornirile și reduce utilizarea procesorului, poate ocupa câteva sute de MB per joc și adaugă un pas suplimentar la pornirea jocurilor Presetări Box64 Vizualizează, modifică și creează presetări Box64 Manager drivere diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a664135eda..2cc3bee585 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1204,8 +1204,10 @@ https://gamenative.app Скорость загрузки Загрузки Автоматически применять рекомендуемые настройки для игр Steam при первом запуске - Кэши кода FEX - Делитесь тем, какой код перевёл FEX, и готовьте эти переводы на устройстве после игры для более быстрого запуска. Кэши занимают место в префиксе игры. + Отправлять карты кода FEX + Отправляет сведения о том, какие блоки кода исполняемых файлов игры выполнялись, без личных данных. Менее 1 МБ на игру. + Использовать кэш кода FEX (экспериментально) + Может ускорить запуск и снизить нагрузку на процессор, может занимать несколько сотен МБ на игру и добавляет дополнительный шаг при запуске игр Автоматически применять известную конфигурацию Просмотр, изменение и создание предустановок Box64 Предустановки Box64 @@ -1223,6 +1225,7 @@ https://gamenative.app Делиться с другими игроками шейдерами, записанными во время игры. Если выключить, общий кэш, уменьшающий подтормаживания, тоже не будет скачиваться Подготовка шейдеров… Подготовка шейдеров %1$d%% + Создание кэша кода… (%1$d/%2$d) Менеджер драйверов Выберите, какие ориентации можно повернуть во время игры diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e9b73dd153..7cd8e8cc19 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1108,6 +1108,7 @@ Oynarken kaydedilen gölgelendiricileri diğer oyuncularla paylaş. Kapatıldığında takılmaları azaltan paylaşılan önbellekler de indirilmez Gölgelendiriciler hazırlanıyor… Gölgelendiriciler hazırlanıyor %1$d%% + Kod önbelleği oluşturuluyor… (%1$d/%2$d) Öykünme Oyun başlatıcı hesapları Oturum açık değil @@ -1257,8 +1258,10 @@ Varsayılan konteyner yapılandırması Bilinen yapılandırmayı otomatik uygula Steam oyunları ilk başlatmada önerilen ayarları otomatik uygular - FEX kod önbellekleri - FEX\'in çevirdiği kodu paylaşın; oynadıktan sonra bu çeviriler daha hızlı başlatma için cihazda hazırlanır. Önbellekler oyunun öneki içinde disk alanı kullanır. + FEX kod haritalarını yükle + Bir oyunun yürütülebilir dosyalarındaki hangi kod bloklarının çalıştığını gönderir, kişisel veri içermez. Oyun başına 1 MB\'tan az. + FEX kod önbelleklerini kullan (deneysel) + Başlatmaları hızlandırabilir ve CPU kullanımını azaltabilir, oyun başına birkaç yüz MB yer kaplayabilir ve oyunları başlatırken ek bir adım ekler Box64 hazır ayarları Box64 hazır ayarlarını görüntüleyin, değiştirin ve oluşturun Sürücü yöneticisi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 67cc1fb142..93a9d01691 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1038,6 +1038,7 @@ Ділитися з іншими гравцями шейдерами, записаними під час гри. Якщо вимкнути, спільний кеш, що зменшує підвисання, теж не завантажуватиметься Підготовка шейдерів… Підготовка шейдерів %1$d%% + Створення кешу коду… (%1$d/%2$d) Емуляція @@ -1192,8 +1193,10 @@ Стандартні налаштування контейнера Автоматично застосовувати відому конфігурацію Автоматично застосовувати рекомендовані налаштування для ігор Steam при першому запуску - Кеші коду FEX - Діліться тим, який код переклав FEX, і готуйте ці переклади на пристрої після гри для швидшого запуску. Кеші займають місце в префіксі гри. + Надсилати карти коду FEX + Надсилає відомості про те, які блоки коду виконуваних файлів гри виконувалися, без особистих даних. Менше 1 МБ на гру. + Використовувати кеш коду FEX (експериментально) + Може пришвидшити запуск і зменшити навантаження на процесор, може займати кілька сотень МБ на гру та додає додатковий крок під час запуску ігор Box64 Пресети Перегляд, зміна та створення пресетів Box64 Менеджер драйвера diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1d8ff7aa2e..d4a35a27b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1029,6 +1029,7 @@ 将游玩时记录的着色器与其他玩家共享。关闭后也不会下载可减少卡顿的共享缓存 正在准备着色器… 正在准备着色器 %1$d%% + 正在构建代码缓存… (%1$d/%2$d) 模拟设置 @@ -1180,8 +1181,10 @@ 默认容器设置 自动匹配预设配置 首次运行 Steam 游戏时,自动加载并应用社区推荐的最佳设置 - FEX 代码缓存 - 分享 FEX 翻译过的代码,并在游戏结束后在本设备上预先准备这些翻译以加快启动。缓存会占用游戏前缀中的存储空间。 + 上传 FEX 代码映射 + 发送游戏可执行文件中运行过的代码块信息,不含个人数据。每个游戏不到 1 MB。 + 使用 FEX 代码缓存(实验性) + 可加快启动速度并降低 CPU 占用,每个游戏可能占用数百 MB,且启动游戏时会增加一个额外步骤 Box64 预设集 查看、修改及创建 Box64 预设集 驱动程序管理器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9d83eebb70..1f4dedb03c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1031,6 +1031,7 @@ 將遊玩時記錄的著色器與其他玩家共享。關閉後也不會下載可減少卡頓的共享快取 正在準備著色器… 正在準備著色器 %1$d%% + 正在建置程式碼快取… (%1$d/%2$d) 模擬設定 @@ -1182,8 +1183,10 @@ 預設容器設定 自動套用預設配置 首次執行 Steam 遊戲時,自動載入並套用社群推薦的最佳設定 - FEX 程式碼快取 - 分享 FEX 翻譯過的程式碼,並在遊戲結束後於本裝置預先準備這些翻譯以加快啟動。快取會佔用遊戲前綴中的儲存空間。 + 上傳 FEX 程式碼對應 + 傳送遊戲執行檔中執行過的程式碼區塊資訊,不含個人資料。每款遊戲不到 1 MB。 + 使用 FEX 程式碼快取(實驗性) + 可加快啟動速度並降低 CPU 使用率,每款遊戲可能占用數百 MB,且啟動遊戲時會多一個步驟 Box64 預設集 檢視、修改及建立 Box64 預設集 驅動程式管理員 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fa2aba605..46f5e8da3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1218,6 +1218,7 @@ Share the shaders recorded while you play with other players. Turning this off also stops downloading the shared caches that reduce stutter Preparing shaders… Preparing shaders %1$d%% + Building code cache… (%1$d/%2$d) Emulation @@ -1369,8 +1370,10 @@ Default Container Config Auto-apply known config Automatically apply recommended settings for Steam games on first launch - FEX code caches - Share which code FEX translated and prepare those translations on this device after you play, for faster launches. Caches take disk space in the game\'s prefix. + Upload FEX code maps + Sends which code blocks of a game\'s executables ran, no personal data. Less than 1 MB per game. + Use FEX code caches (experimental) + Can speed up launches and reduce CPU usage, may use a few hundred MB per game and adds an additional step when launching games Box64 Presets View, modify, and create Box64 presets Driver Manager From a7274524f61187933ea4b55fe5fa3f280fe73f19 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 12:36:34 +0530 Subject: [PATCH 08/10] Harden staged shader pools, the Skip button and the settings toggles - A step only withdraws its own Skip offer, so a late finish cannot hide a newer step's button. - A response that arrives while its call is cancelled is closed. - A staged pool counts only when its size matches; a leftover partial download is deleted at launch. - Adopting a staged pool keeps the container's pool until the replacement and its metadata are written. - An uninstall during the install-time download leaves no staged pool, and a launch waits for a step that has not started yet. - The start-up cleanup keeps pools staged in this process, whose installed state can be written after the step. - GOG install step events carry the game's id; a GOG uninstall that cannot run keeps the staged pool. - The start-up check no longer swallows cancellation. - Turning off the map upload shows the cache toggle off at once. - The upload size is described as usual, not guaranteed. --- .../service/gog/GOGDownloadManager.kt | 9 ++- .../app/gamenative/service/gog/GOGService.kt | 4 +- .../shadercache/FossilizeManager.kt | 74 ++++++++++++++----- .../app/gamenative/shadercache/StagedPools.kt | 45 +++++++---- .../screen/settings/SettingsGroupEmulation.kt | 5 +- .../java/app/gamenative/utils/LaunchSkip.kt | 24 ++++-- .../java/app/gamenative/utils/NetworkUtils.kt | 3 +- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../gamenative/shadercache/StagedPoolsTest.kt | 25 +++++++ .../app/gamenative/utils/LaunchSkipTest.kt | 35 +++++++++ 25 files changed, 194 insertions(+), 62 deletions(-) create mode 100644 app/src/test/java/app/gamenative/utils/LaunchSkipTest.kt diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 652d1ed4ef..d456f20fec 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -841,7 +841,14 @@ class GOGDownloadManager @Inject constructor( * Used by both Gen 2 and Gen 1 success paths. */ private suspend fun finalizeInstallSuccess(gameId: String, installPath: File, downloadInfo: DownloadInfo) { - app.gamenative.shadercache.FossilizeManager.downloadPoolDuringInstall(context, downloadInfo, "GOG_$gameId", "gog", gameId) + app.gamenative.shadercache.FossilizeManager.downloadPoolDuringInstall( + context, + downloadInfo, + "GOG_$gameId", + "gog", + gameId, + eventGameId = gameId.toIntOrNull() ?: 0, + ) downloadInfo.updateStatusMessage("Updating database...") try { val game = gogManager.getGameFromDbById(gameId) diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 445cad258d..8bfb63d226 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -546,9 +546,9 @@ class GOGService : Service() { app.gamenative.service.cloud.CloseSyncTracker.awaitIdle( app.gamenative.service.cloud.CloseSyncTracker.keyOf(GameSource.GOG, libraryItem.gameId), ) + val gogManager = getInstance()?.gogManager ?: return Result.failure(Exception("Service not available")) app.gamenative.shadercache.FossilizeManager.dropStagedPool(context, "gog", libraryItem.gameId.toString()) - return getInstance()?.gogManager?.deleteGame(context, libraryItem) - ?: Result.failure(Exception("Service not available")) + return gogManager.deleteGame(context, libraryItem) } /** diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index ab86c70bc5..ee3021737b 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -111,6 +111,13 @@ object FossilizeManager { private val startCheckRan = AtomicBoolean(false) private val stagingJobs = ConcurrentHashMap() + // Counts the uninstalls of each game; an install-time download that started before one does not keep its pool. + private val stagedPoolDrops = ConcurrentHashMap() + private val stagingLock = Any() + + // Games whose pool an install staged in this process; their installed state may be written after the step. + private val stagedThisProcess: MutableSet = ConcurrentHashMap.newKeySet() + @Volatile private var activeSessionDir: File? = null @@ -275,7 +282,7 @@ object FossilizeManager { } catch (t: Throwable) { Timber.tag(TAG).e(t, "Shader cache preparation failed") } finally { - LaunchSkip.withdraw() + LaunchSkip.withdraw(skipped) } } @@ -291,12 +298,13 @@ object FossilizeManager { val current = runCatching { JSONObject(metaFile.readText()) }.getOrNull() ?.takeIf { poolFile(container, key).isFile } val staging = stagingJobs[key.game] - if (staging != null && staging.isActive) { + // A registered step may not have started yet; join() starts it. + if (staging != null && !staging.isCompleted) { // The game's install is still downloading this pool; a second download would race it. PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) - LaunchSkip.offer { skipped.set(true) } + LaunchSkip.offer(skipped) { skipped.set(true) } awaitWork(POOL_DOWNLOAD_TIMEOUT_MS, shouldStop, cancelOnStop = false) { staging.join() } - if (staging.isActive) { + if (!staging.isCompleted) { Timber.tag(TAG).i("Install-time pool download for %s still running; launching without it", key) return current } @@ -333,7 +341,7 @@ object FossilizeManager { return local } PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) - LaunchSkip.offer { skipped.set(true) } + LaunchSkip.offer(skipped) { skipped.set(true) } val target = poolFile(container, key) val partial = File(target.parentFile, target.name + ".part") // Cancelled and joined so the .part file is no longer written once this returns. @@ -374,16 +382,25 @@ object FossilizeManager { * Install step: downloads the pool of the game being installed into app storage, where its first launch picks it * up. Shows on the install as a post-install step. Ends quietly on any failure; cancelling the install cancels it. */ - suspend fun downloadPoolDuringInstall(context: Context, downloadInfo: DownloadInfo, containerId: String, store: String, gameId: String) { + suspend fun downloadPoolDuringInstall( + context: Context, + downloadInfo: DownloadInfo, + containerId: String, + store: String, + gameId: String, + eventGameId: Int = downloadInfo.gameId, + ) { if (!PrefManager.sharedShaderCache) return val key = ShaderCacheKey.storeGame(store, gameId)?.let(::ShaderCacheKey) ?: return + val generation = stagedPoolDrops[key.game] ?: 0 + stagedThisProcess.add(key.game) val appContext = context.applicationContext downloadInfo.setPostInstallSyncing(true) downloadInfo.updateStatusMessage(appContext.getString(R.string.shader_cache_preparing)) - PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(downloadInfo.gameId, true)) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, true)) try { coroutineScope { - val step = async(start = CoroutineStart.LAZY) { stagePool(appContext, downloadInfo, containerId, key) } + val step = async(start = CoroutineStart.LAZY) { stagePool(appContext, downloadInfo, containerId, key, generation) } val running = stagingJobs.putIfAbsent(key.game, step) if (running != null) { step.cancel() @@ -404,11 +421,11 @@ object FossilizeManager { } finally { downloadInfo.setPostInstallSyncing(false) downloadInfo.updateStatusMessage(null) - PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(downloadInfo.gameId, false)) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, false)) } } - private suspend fun stagePool(context: Context, downloadInfo: DownloadInfo, containerId: String, key: ShaderCacheKey) { + private suspend fun stagePool(context: Context, downloadInfo: DownloadInfo, containerId: String, key: ShaderCacheKey, generation: Int) { val dir = StagedPools.dir(context.filesDir) val container = runCatching { ContainerManager(context).getContainerById(containerId) }.getOrNull() val local = container?.let { c -> @@ -458,12 +475,19 @@ object FossilizeManager { Timber.tag(TAG).w("Install-time pool download for %s failed (%s)", key, if (hash == null) result ?: "timed out" else "sha256 mismatch") return } - StagedPools.metaFile(dir, key).delete() - if (!partial.renameTo(target)) { - partial.delete() - return + synchronized(stagingLock) { + if ((stagedPoolDrops[key.game] ?: 0) != generation) { + partial.delete() + Timber.tag(TAG).i("%s was uninstalled during its install-time pool download", key) + return + } + StagedPools.metaFile(dir, key).delete() + if (!partial.renameTo(target)) { + partial.delete() + return + } + FileUtils.writeString(StagedPools.metaFile(dir, key), StagedPools.meta(pool.version, pool.sha256, target.length(), pool.entries).toString()) } - FileUtils.writeString(StagedPools.metaFile(dir, key), StagedPools.meta(pool.version, pool.sha256, target.length(), pool.entries).toString()) Timber.tag(TAG).i("Staged pool v%d for %s (%d entries, %d bytes)", pool.version, key, pool.entries, target.length()) } @@ -471,8 +495,11 @@ object FossilizeManager { fun dropStagedPool(context: Context, store: String, gameId: String) { val key = ShaderCacheKey.storeGame(store, gameId)?.let(::ShaderCacheKey) ?: return runCatching { - stagingJobs[key.game]?.cancel() - StagedPools.drop(StagedPools.dir(context.filesDir), key) + synchronized(stagingLock) { + stagedPoolDrops.merge(key.game, 1, Int::plus) + stagingJobs[key.game]?.cancel() + StagedPools.drop(StagedPools.dir(context.filesDir), key) + } }.onFailure { Timber.tag(TAG).w(it, "Could not delete the staged pool of %s", key) } } @@ -507,7 +534,7 @@ object FossilizeManager { delay(START_CHECK_DELAY_MS) val games = installedGames(context, appInfoDao, gogGameDao, epicGameDao, amazonGameDao) runCatching { - StagedPools.dropAllExcept(StagedPools.dir(context.filesDir), (games + stagingJobs.keys).map(::ShaderCacheKey)) + StagedPools.dropAllExcept(StagedPools.dir(context.filesDir), (games + stagedThisProcess).map(::ShaderCacheKey)) } for (chunk in games.chunked(ShaderCacheApi.MAX_CHECK_GAMES)) { val result = ShaderCacheApi.checkPools(chunk) @@ -537,7 +564,14 @@ object FossilizeManager { ): List { val games = linkedSetOf() suspend fun add(store: String, ids: suspend () -> List) { - runCatching { ids() }.getOrNull()?.forEach { id -> ShaderCacheKey.storeGame(store, id)?.let(games::add) } + val found = try { + ids() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null + } + found?.forEach { id -> ShaderCacheKey.storeGame(store, id)?.let(games::add) } } add("steam") { appInfoDao.getAll().filter { it.isDownloaded }.map { it.id.toString() } } add("gog") { gogGameDao.getInstalledGames().map { it.id } } @@ -607,7 +641,7 @@ object FossilizeManager { val workDir = poolDir(container) val started = System.currentTimeMillis() PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(context.getString(R.string.shader_cache_preparing))) - LaunchSkip.offer { skipped.set(true) } + LaunchSkip.offer(skipped) { skipped.set(true) } var command = listOf(replayBin.absolutePath) var outcome = runReplay(command + args + "--progress", env, workDir, started, context, isCancelled) diff --git a/app/src/main/java/app/gamenative/shadercache/StagedPools.kt b/app/src/main/java/app/gamenative/shadercache/StagedPools.kt index 8157701280..07a569a455 100644 --- a/app/src/main/java/app/gamenative/shadercache/StagedPools.kt +++ b/app/src/main/java/app/gamenative/shadercache/StagedPools.kt @@ -21,9 +21,14 @@ internal object StagedPools { fun readMeta(file: File): JSONObject? = if (file.isFile) runCatching { JSONObject(file.readText()) }.getOrNull() else null + private fun partialFile(dir: File, key: ShaderCacheKey) = File(dir, "${key.fileName}.foz.part") + + private fun isComplete(staged: File, meta: JSONObject?): Boolean = + meta != null && staged.isFile && staged.length() == meta.optLong("size", -1L) + /** Version of the complete staged pool of [key], or null if there is none. */ fun stagedVersion(dir: File, key: ShaderCacheKey): Long? = - readMeta(metaFile(dir, key))?.takeIf { poolFile(dir, key).isFile }?.optLong("version") + readMeta(metaFile(dir, key))?.takeIf { isComplete(poolFile(dir, key), it) }?.optLong("version") /** * Moves the staged pool of [key] into [poolDir] as [poolFile] with [poolMetaFile] when it is newer than @@ -33,34 +38,44 @@ internal object StagedPools { fun adopt(dir: File, key: ShaderCacheKey, poolDir: File, poolFile: File, poolMetaFile: File, localVersion: Long?): JSONObject? { val staged = poolFile(dir, key) val stagedMeta = metaFile(dir, key) - if (!staged.exists() && !stagedMeta.exists()) return null + if (!staged.exists() && !stagedMeta.exists()) { + // Left by an install that was stopped mid-download. + partialFile(dir, key).delete() + return null + } val meta = readMeta(stagedMeta) - val usable = meta != null && staged.isFile && staged.length() == meta.optLong("size", -1L) && - (localVersion == null || meta.optLong("version") > localVersion) - if (!usable) { + if (!isComplete(staged, meta) || (localVersion != null && meta!!.optLong("version") <= localVersion)) { drop(dir, key) return null } + // The container's current pool stays until the replacement and its metadata are written in full. poolDir.mkdirs() - poolDir.listFiles()?.forEach { if (it.isFile) it.delete() } - val moved = staged.renameTo(poolFile) || runCatching { - staged.copyTo(poolFile, overwrite = true) - staged.delete() - }.isSuccess - if (!moved) { - poolFile.delete() + val newPool = File(poolDir, "${poolFile.name}.adopt") + val newMeta = File(poolDir, "${poolMetaFile.name}.adopt") + val written = (staged.renameTo(newPool) || runCatching { staged.copyTo(newPool, overwrite = true) }.isSuccess) && + runCatching { newMeta.writeText(meta.toString()) }.isSuccess + if (!written) { + newPool.delete() + newMeta.delete() + drop(dir, key) + return null + } + poolMetaFile.delete() + if (!newPool.renameTo(poolFile) || !newMeta.renameTo(poolMetaFile)) { + newPool.delete() + newMeta.delete() drop(dir, key) return null } - poolMetaFile.writeText(meta.toString()) - stagedMeta.delete() + poolDir.listFiles()?.forEach { if (it.isFile && it != poolFile && it != poolMetaFile) it.delete() } + drop(dir, key) return meta } fun drop(dir: File, key: ShaderCacheKey) { poolFile(dir, key).delete() metaFile(dir, key).delete() - File(dir, "${key.fileName}.foz.part").delete() + partialFile(dir, key).delete() } /** Deletes every staged file that does not belong to one of [keep]. */ diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt index 324773159b..6b38e6a13a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt @@ -112,7 +112,8 @@ fun SettingsGroupEmulation() { onCheckedChange = { fexCodeMapUploadEnabled = it PrefManager.fexCodeMapUploadEnabled = it - fexCodeCacheUseEnabled = PrefManager.fexCodeCacheUseEnabled + // The preference writes are asynchronous; reading them back here could return the old value. + if (!it) fexCodeCacheUseEnabled = false }, ) SettingsSwitch( @@ -123,7 +124,7 @@ fun SettingsGroupEmulation() { subtitle = { Text(text = stringResource(R.string.settings_emulation_fex_code_cache_use_subtitle)) }, onCheckedChange = { PrefManager.fexCodeCacheUseEnabled = it - fexCodeCacheUseEnabled = PrefManager.fexCodeCacheUseEnabled + fexCodeCacheUseEnabled = it && fexCodeMapUploadEnabled }, ) SettingsMenuLink( diff --git a/app/src/main/java/app/gamenative/utils/LaunchSkip.kt b/app/src/main/java/app/gamenative/utils/LaunchSkip.kt index 659908a61d..918faccca0 100644 --- a/app/src/main/java/app/gamenative/utils/LaunchSkip.kt +++ b/app/src/main/java/app/gamenative/utils/LaunchSkip.kt @@ -11,19 +11,33 @@ import kotlinx.coroutines.flow.StateFlow object LaunchSkip { private val current = MutableStateFlow<(() -> Unit)?>(null) + // Who made the current offer; only that owner can withdraw it. + private var owner: Any? = null + /** The action of the step that can be skipped now, or null. */ val action: StateFlow<(() -> Unit)?> = current - /** Shows the button; [onSkip] runs once when it is pressed and the button goes away. */ - fun offer(onSkip: () -> Unit) { + /** + * Shows the button for [owner], replacing any earlier offer; [onSkip] runs once when it is + * pressed and the button goes away. + */ + fun offer(owner: Any, onSkip: () -> Unit) { lateinit var once: () -> Unit once = { if (current.compareAndSet(once, null)) onSkip() } - current.value = once + synchronized(this) { + this.owner = owner + current.value = once + } } - fun withdraw() { - current.value = null + /** Hides the button if [owner] made the current offer; a later offer by another step stays. */ + fun withdraw(owner: Any) { + synchronized(this) { + if (this.owner !== owner) return + this.owner = null + current.value = null + } } } diff --git a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt index f7e22fa6aa..a87a7ccc88 100644 --- a/app/src/main/java/app/gamenative/utils/NetworkUtils.kt +++ b/app/src/main/java/app/gamenative/utils/NetworkUtils.kt @@ -97,7 +97,8 @@ suspend fun Call.executeCancellable(block: (Response) -> T): T { override fun onFailure(call: Call, e: IOException) = continuation.resumeWithException(e) override fun onResponse(call: Call, response: Response) { - if (continuation.isActive) continuation.resume(response) else response.close() + // Closed by the handler when cancellation wins the race, before or after this resume. + continuation.resume(response) { _, value, _ -> value.close() } } }, ) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 8d3aaa5026..ddbd9294a6 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1013,7 +1013,7 @@ Anvend kendt konfiguration automatisk Anvend automatisk anbefalede indstillinger for Steam-spil ved første start Upload FEX-kodekort - Sender hvilke kodeblokke i et spils programfiler der blev kørt, ingen personlige data. Under 1 MB pr. spil. + Sender hvilke kodeblokke i et spils programfiler der blev kørt, ingen personlige data. Normalt under 1 MB pr. spil. Brug FEX-kodecaches (eksperimentel) Kan gøre opstart hurtigere og reducere CPU-forbruget, kan bruge et par hundrede MB pr. spil og tilføjer et ekstra trin, når spil startes Box64-forudindstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3c2523721b..7a70f4b8e0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1157,7 +1157,7 @@ Bekannte Konfiguration automatisch anwenden Beim ersten Start automatisch empfohlene Einstellungen für Steam-Spiele anwenden FEX-Code-Maps hochladen - Sendet, welche Codeblöcke der ausführbaren Dateien eines Spiels liefen, keine persönlichen Daten. Weniger als 1 MB pro Spiel. + Sendet, welche Codeblöcke in den ausführbaren Dateien eines Spiels ausgeführt wurden, aber keine persönlichen Daten. Normalerweise weniger als 1 MB pro Spiel. FEX-Code-Caches verwenden (experimentell) Kann Starts beschleunigen und die CPU-Last senken, kann einige hundert MB pro Spiel belegen und fügt beim Starten von Spielen einen zusätzlichen Schritt hinzu Box64-Voreinstellungen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 73b7712d40..cec9f117af 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1205,7 +1205,7 @@ Aplicar configuración conocida automáticamente Aplica automáticamente la configuración recomendada para juegos de Steam al iniciarlos por primera vez. Subir mapas de código de FEX - Envía qué bloques de código de los ejecutables de un juego se ejecutaron, sin datos personales. Menos de 1 MB por juego. + Envía qué bloques de código de los ejecutables de un juego se ejecutaron, sin datos personales. Normalmente menos de 1 MB por juego. Usar cachés de código de FEX (experimental) Puede acelerar los inicios y reducir el uso de CPU, puede ocupar unos cientos de MB por juego y añade un paso adicional al iniciar juegos Preajustes de Box64 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a0c240c548..c13afc21d3 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1215,7 +1215,7 @@ Appliquer automatiquement la configuration connue Appliquer automatiquement les paramètres recommandés pour les jeux Steam au premier lancement Envoyer les cartes de code FEX - Envoie les blocs de code des exécutables d\'un jeu qui ont été exécutés, sans données personnelles. Moins de 1 Mo par jeu. + Envoie les blocs de code des exécutables d\'un jeu qui ont été exécutés, sans données personnelles. Généralement moins de 1 Mo par jeu. Utiliser les caches de code FEX (expérimental) Peut accélérer les lancements et réduire l\'utilisation du processeur, peut occuper quelques centaines de Mo par jeu et ajoute une étape supplémentaire au lancement des jeux Préréglages Box64 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 47bee48f2c..1b16b48ff6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1189,7 +1189,7 @@ Applica automaticamente config nota Applica automaticamente le impostazioni consigliate per i giochi Steam al primo avvio Carica le mappe di codice FEX - Invia quali blocchi di codice degli eseguibili di un gioco sono stati eseguiti, nessun dato personale. Meno di 1 MB per gioco. + Invia quali blocchi di codice degli eseguibili di un gioco sono stati eseguiti, nessun dato personale. Di solito meno di 1 MB per gioco. Usa le cache di codice FEX (sperimentale) Può velocizzare gli avvii e ridurre l\'uso della CPU, può occupare qualche centinaio di MB per gioco e aggiunge un passaggio in più all\'avvio dei giochi Preset Box64 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 73439cdcd7..abbb4eeab2 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1360,7 +1360,7 @@ 既知の構成を自動適用する 初回起動時に Steam ゲームの推奨設定を自動的に適用する FEX コードマップをアップロード - ゲームの実行ファイルのどのコードブロックが実行されたかを送信します。個人データは含まれません。1 ゲームあたり 1 MB 未満。 + ゲームの実行ファイルのどのコードブロックが実行されたかを送信します。個人データは含まれません。通常は 1 ゲームあたり 1 MB 未満。 FEX コードキャッシュを使用 (実験的) 起動を高速化し CPU 使用率を下げられる場合がありますが、1 ゲームあたり数百 MB を使用することがあり、ゲーム起動時に追加の手順が入ります Box64 プリセット diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 307d4d6edd..f88ef5f43f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2238,7 +2238,7 @@ 알려진 설정 자동 적용 Steam 게임을 처음 실행할 때 권장 설정을 자동으로 적용합니다 FEX 코드 맵 업로드 - 게임 실행 파일에서 실행된 코드 블록 정보를 보냅니다. 개인 데이터는 포함되지 않습니다. 게임당 1MB 미만. + 게임 실행 파일에서 실행된 코드 블록 정보를 보냅니다. 개인 데이터는 포함되지 않습니다. 보통 게임당 1MB 미만. FEX 코드 캐시 사용 (실험적) 실행 속도를 높이고 CPU 사용량을 줄일 수 있지만, 게임당 수백 MB를 사용할 수 있으며 게임 실행 시 추가 단계가 생깁니다 Wi-Fi 또는 LAN에 연결되어 있지 않습니다 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4c5c933def..c0b3578192 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1208,7 +1208,7 @@ Automatycznie zastosuj znaną konfigurację Automatycznie zastosuj zalecane ustawienia dla gier Steam przy pierwszym uruchomieniu Wysyłaj mapy kodu FEX - Wysyła informacje, które bloki kodu plików wykonywalnych gry zostały uruchomione, bez danych osobowych. Mniej niż 1 MB na grę. + Wysyła informacje, które bloki kodu plików wykonywalnych gry zostały uruchomione, bez danych osobowych. Zwykle mniej niż 1 MB na grę. Używaj pamięci podręcznej kodu FEX (eksperymentalne) Może przyspieszyć uruchamianie i zmniejszyć użycie procesora, może zajmować kilkaset MB na grę i dodaje dodatkowy krok przy uruchamianiu gier Presety Box64 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3f30060bdd..51347dab66 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1013,7 +1013,7 @@ Aplicar configuração conhecida automaticamente Aplica automaticamente as configurações recomendadas para jogos Steam na primeira inicialização Enviar mapas de código do FEX - Envia quais blocos de código dos executáveis de um jogo foram executados, sem dados pessoais. Menos de 1 MB por jogo. + Envia quais blocos de código dos executáveis de um jogo foram executados, sem dados pessoais. Normalmente menos de 1 MB por jogo. Usar caches de código do FEX (experimental) Pode acelerar as inicializações e reduzir o uso da CPU, pode ocupar algumas centenas de MB por jogo e adiciona uma etapa extra ao iniciar jogos Predefinições do Box64 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b239e424b3..1f43490592 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1199,7 +1199,7 @@ Aplică automat configurația cunoscută Aplică automat setările recomandate pentru jocurile Steam la prima lansare Încarcă hărțile de cod FEX - Trimite ce blocuri de cod din executabilele unui joc au rulat, fără date personale. Sub 1 MB per joc. + Trimite ce blocuri de cod din executabilele unui joc au rulat, fără date personale. De obicei sub 1 MB per joc. Folosește memoria cache de cod FEX (experimental) Poate accelera pornirile și reduce utilizarea procesorului, poate ocupa câteva sute de MB per joc și adaugă un pas suplimentar la pornirea jocurilor Presetări Box64 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2cc3bee585..359cfeaa34 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1205,7 +1205,7 @@ https://gamenative.app Загрузки Автоматически применять рекомендуемые настройки для игр Steam при первом запуске Отправлять карты кода FEX - Отправляет сведения о том, какие блоки кода исполняемых файлов игры выполнялись, без личных данных. Менее 1 МБ на игру. + Отправляет сведения о том, какие блоки кода исполняемых файлов игры выполнялись, без личных данных. Обычно менее 1 МБ на игру. Использовать кэш кода FEX (экспериментально) Может ускорить запуск и снизить нагрузку на процессор, может занимать несколько сотен МБ на игру и добавляет дополнительный шаг при запуске игр Автоматически применять известную конфигурацию diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7cd8e8cc19..3856b988c1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1259,7 +1259,7 @@ Bilinen yapılandırmayı otomatik uygula Steam oyunları ilk başlatmada önerilen ayarları otomatik uygular FEX kod haritalarını yükle - Bir oyunun yürütülebilir dosyalarındaki hangi kod bloklarının çalıştığını gönderir, kişisel veri içermez. Oyun başına 1 MB\'tan az. + Bir oyunun yürütülebilir dosyalarındaki hangi kod bloklarının çalıştığını gönderir, kişisel veri içermez. Genellikle oyun başına 1 MB\'tan az. FEX kod önbelleklerini kullan (deneysel) Başlatmaları hızlandırabilir ve CPU kullanımını azaltabilir, oyun başına birkaç yüz MB yer kaplayabilir ve oyunları başlatırken ek bir adım ekler Box64 hazır ayarları diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 93a9d01691..da72b500c7 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1194,7 +1194,7 @@ Автоматично застосовувати відому конфігурацію Автоматично застосовувати рекомендовані налаштування для ігор Steam при першому запуску Надсилати карти коду FEX - Надсилає відомості про те, які блоки коду виконуваних файлів гри виконувалися, без особистих даних. Менше 1 МБ на гру. + Надсилає відомості про те, які блоки коду виконуваних файлів гри виконувалися, без особистих даних. Зазвичай менше 1 МБ на гру. Використовувати кеш коду FEX (експериментально) Може пришвидшити запуск і зменшити навантаження на процесор, може займати кілька сотень МБ на гру та додає додатковий крок під час запуску ігор Box64 Пресети diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d4a35a27b0..a9c53196d0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1182,7 +1182,7 @@ 自动匹配预设配置 首次运行 Steam 游戏时,自动加载并应用社区推荐的最佳设置 上传 FEX 代码映射 - 发送游戏可执行文件中运行过的代码块信息,不含个人数据。每个游戏不到 1 MB。 + 发送游戏可执行文件中运行过的代码块信息,不含个人数据。每个游戏通常不到 1 MB。 使用 FEX 代码缓存(实验性) 可加快启动速度并降低 CPU 占用,每个游戏可能占用数百 MB,且启动游戏时会增加一个额外步骤 Box64 预设集 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1f4dedb03c..e7f410dc72 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1184,7 +1184,7 @@ 自動套用預設配置 首次執行 Steam 遊戲時,自動載入並套用社群推薦的最佳設定 上傳 FEX 程式碼對應 - 傳送遊戲執行檔中執行過的程式碼區塊資訊,不含個人資料。每款遊戲不到 1 MB。 + 傳送遊戲執行檔中執行過的程式碼區塊資訊,不含個人資料。每款遊戲通常不到 1 MB。 使用 FEX 程式碼快取(實驗性) 可加快啟動速度並降低 CPU 使用率,每款遊戲可能占用數百 MB,且啟動遊戲時會多一個步驟 Box64 預設集 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46f5e8da3d..befefea19b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1371,7 +1371,7 @@ Auto-apply known config Automatically apply recommended settings for Steam games on first launch Upload FEX code maps - Sends which code blocks of a game\'s executables ran, no personal data. Less than 1 MB per game. + Sends which code blocks of a game\'s executables ran, no personal data. Usually less than 1 MB per game. Use FEX code caches (experimental) Can speed up launches and reduce CPU usage, may use a few hundred MB per game and adds an additional step when launching games Box64 Presets diff --git a/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt b/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt index b9c5a46aab..ca570b26a8 100644 --- a/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt +++ b/app/src/test/java/app/gamenative/shadercache/StagedPoolsTest.kt @@ -82,4 +82,29 @@ class StagedPoolsTest { assertEquals(setOf("${key.fileName}.foz", "${key.fileName}.json"), staged.list()!!.toSet()) } + + @Test fun stagedVersionIgnoresAPoolWhoseSizeDoesNotMatch() { + stage(5, ByteArray(10), size = 11) + + assertNull(StagedPools.stagedVersion(staged, key)) + } + + @Test fun deletesAPartialDownloadLeftByAnInterruptedInstall() { + val partial = File(staged, "${key.fileName}.foz.part").apply { writeBytes(ByteArray(4)) } + + assertNull(adopt(localVersion = null)) + assertFalse(partial.exists()) + } + + @Test fun keepsTheContainersPoolWhenTheReplacementCannotBeWritten() { + poolDir.mkdirs() + poolFile.writeBytes(byteArrayOf(9)) + poolMeta.writeText(StagedPools.meta(4, "cd", 1, 1).toString()) + File(poolDir, "${poolMeta.name}.adopt").mkdirs() + stage(5, ByteArray(10)) + + assertNull(adopt(localVersion = 4)) + assertArrayEquals(byteArrayOf(9), poolFile.readBytes()) + assertEquals(4L, JSONObject(poolMeta.readText()).getLong("version")) + } } diff --git a/app/src/test/java/app/gamenative/utils/LaunchSkipTest.kt b/app/src/test/java/app/gamenative/utils/LaunchSkipTest.kt new file mode 100644 index 0000000000..8afa04654d --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/LaunchSkipTest.kt @@ -0,0 +1,35 @@ +package app.gamenative.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class LaunchSkipTest { + @Test fun anOlderStepCannotWithdrawANewerOffer() { + val older = Any() + val newer = Any() + var skipped = 0 + LaunchSkip.offer(older) {} + LaunchSkip.offer(newer) { skipped++ } + + LaunchSkip.withdraw(older) + + val action = LaunchSkip.action.value + assertNotNull(action) + action!!() + action() + assertEquals(1, skipped) + assertNull(LaunchSkip.action.value) + LaunchSkip.withdraw(newer) + } + + @Test fun theOwnerWithdrawsItsOffer() { + val owner = Any() + LaunchSkip.offer(owner) {} + + LaunchSkip.withdraw(owner) + + assertNull(LaunchSkip.action.value) + } +} From 89ceb69780707df3774164d5336a89de98998d0b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 12:36:34 +0530 Subject: [PATCH 09/10] Keep FEX code cache work off the default path and out of prerequisite starts - With code caches off, a session's end links and merges nothing. - All stops of one compile for a launch share a single wait. - The exit path takes its own session's launcher before running anything in the background. - A prerequisite installer start neither invalidates, builds nor loads code caches. --- .../ui/screen/xserver/XServerScreen.kt | 12 ++-- .../app/gamenative/utils/FexCodeCaches.kt | 55 +++++++++++++------ .../BionicProgramLauncherComponent.java | 5 +- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index b0dfbcb2c1..28d6109954 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -4750,9 +4750,12 @@ private fun setupXEnvironment( } fun chainPreInstallSteps(remaining: List) { - (guestProgramLauncherComponent as? BionicProgramLauncherComponent)?.setFEXCorePreset( - if (remaining.firstOrNull()?.marker.let { it == Marker.GOG_SCRIPT_INSTALLED || it == Marker.GOG_BONUS_INSTALLER_RUN }) FEXCorePreset.STABILITY else container.fexCorePreset, - ) + (guestProgramLauncherComponent as? BionicProgramLauncherComponent)?.apply { + setFEXCorePreset( + if (remaining.firstOrNull()?.marker.let { it == Marker.GOG_SCRIPT_INSTALLED || it == Marker.GOG_BONUS_INSTALLER_RUN }) FEXCorePreset.STABILITY else container.fexCorePreset, + ) + setGameStart(remaining.isEmpty()) + } if (remaining.isEmpty()) { guestProgramLauncherComponent.setGuestExecutable(gameExecutable) guestProgramLauncherComponent.setTerminationCallback(gameTerminationCallback) @@ -5554,9 +5557,10 @@ private fun exit( // had to kill loses only its unflushed tail. Ship what is on disk once the environment is down. (frameRating?.context ?: PluviaApp.xServerView?.context)?.applicationContext?.let { appContext -> val sessionLengthSec = frameRating?.sessionLengthSec?.toInt() + val sessionLauncher = app.gamenative.utils.FexCodeCaches.endSession(container) CoroutineScope(Dispatchers.IO).launch { // Keeps this session's maps for the on-device compile before the upload deletes them; the compile runs on its own. - runCatching { app.gamenative.utils.FexCodeCaches.afterSession(appContext, container) } + runCatching { app.gamenative.utils.FexCodeCaches.afterSession(appContext, container, sessionLauncher) } .onFailure { Timber.w(it, "Code cache compile could not start") } runCatching { FexCodeMaps.uploadAfterSession(appContext, container, appId, sessionLengthSec) } .onFailure { Timber.w(it, "Code map upload failed") } diff --git a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt index 3014dffe9e..72c9b90fca 100644 --- a/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt +++ b/app/src/main/java/app/gamenative/utils/FexCodeCaches.kt @@ -89,6 +89,10 @@ object FexCodeCaches { @Volatile private var stopReason: String? = null + // The compile a launch asked to stop and when the launch stops waiting for it, shared by every stop of that launch. + private var stoppedJob: Job? = null + private var stopDeadline = 0L + @Volatile private var sessionLauncher: BionicProgramLauncherComponent? = null @@ -117,11 +121,13 @@ object FexCodeCaches { * other FEX settings, or all of them while code caches are off. Never throws. */ @JvmStatic - fun onGuestStarting(context: Context, contentsManager: ContentsManager, launcher: BionicProgramLauncherComponent) { + fun onGuestStarting(context: Context, contentsManager: ContentsManager, launcher: BionicProgramLauncherComponent, gameStart: Boolean) { try { stopForLaunch("a game is starting") - sessionLauncher = launcher + synchronized(lock) { sessionLauncher = launcher } captureLauncher = null + // A prerequisite installer can run with other FEX settings; only the game's own start may judge the caches. + if (!gameStart) return val container = launcher.container ?: return if (!FexCodeMaps.supportsCodeMaps(context, contentsManager, container)) return val state = readState(container) @@ -158,16 +164,25 @@ object FexCodeCaches { } /** - * Stops a compile left running from the last session and waits for it for at most [STOP_WAIT_MS]; - * its unfinished images stay pending for the build before this launch. Never throws. + * Stops a compile left running from the last session and waits for it for at most [STOP_WAIT_MS] + * in total over every stop of the same compile; its unfinished images stay pending for the build + * before this launch. Never throws. */ @JvmStatic fun stopForLaunch(reason: String) { try { - val running = synchronized(lock) { job?.takeIf { it.isActive && !jobBeforeLaunch } } ?: return + val (running, deadline) = synchronized(lock) { + val running = job?.takeIf { it.isActive && !jobBeforeLaunch } ?: return + if (stoppedJob !== running) { + stoppedJob = running + stopDeadline = SystemClock.elapsedRealtime() + STOP_WAIT_MS + } + running to stopDeadline + } stopReason = reason Timber.tag(TAG).i("Stopping the code cache compile: $reason") - val ended = runBlocking { withTimeoutOrNull(STOP_WAIT_MS) { running.join() } } + val wait = deadline - SystemClock.elapsedRealtime() + val ended = if (wait > 0) runBlocking { withTimeoutOrNull(wait) { running.join() } } else null if (ended == null) Timber.tag(TAG).w("Code cache compile still running $STOP_WAIT_MS ms after the stop") } catch (e: Exception) { Timber.tag(TAG).w(e, "Could not stop the code cache compile") @@ -175,13 +190,21 @@ object FexCodeCaches { } /** - * Called once the session's environment is down. Links this session's maps into the inbox (the - * upload deletes the originals) and starts the background compile; returns at once. + * Hands over the launcher of the session that is ending and forgets it. Called on the exit path + * before anything runs asynchronously, so a later launch's launcher is never taken. + */ + fun endSession(container: Container): BionicProgramLauncherComponent? = synchronized(lock) { + sessionLauncher?.takeIf { it.container?.id == container.id }?.also { sessionLauncher = null } + } + + /** + * Called once the session's environment is down, with the launcher [endSession] returned. Links this + * session's maps into the inbox (the upload deletes the originals) and starts the background compile; + * returns at once. Does nothing while code caches are off. */ - fun afterSession(context: Context, container: Container) { - val launcher = sessionLauncher?.takeIf { it.container?.id == container.id } - sessionLauncher = null + fun afterSession(context: Context, container: Container, launcher: BionicProgramLauncherComponent?) { try { + if (!PrefManager.fexCodeCacheUseEnabled) return if (!FexCodeMaps.captureEnabled(context, container)) { Timber.tag(TAG).i("Skip compile for ${container.id}: FEX code map recording is off or the FEX pack has no offline compiler") return @@ -289,12 +312,12 @@ object FexCodeCaches { Timber.tag(TAG).i("Skip pre-launch build for ${container.id}: the compile from the last session is still running") return } - LaunchSkip.offer { skipped.set(true) } + LaunchSkip.offer(skipped) { skipped.set(true) } runBlocking { work.join() } } catch (e: Exception) { Timber.tag(TAG).w(e, "Pre-launch build for ${container.id} failed") } finally { - LaunchSkip.withdraw() + LaunchSkip.withdraw(skipped) } } @@ -560,9 +583,9 @@ object FexCodeCaches { ) val started = SystemClock.elapsedRealtime() val env = EnvVars().apply { - // The real prefix path, not the home symlink, which a launch of another container repoints. It must - // be the canonical spelling the overlay uses as its upper dir: any other spelling of a thin prefix - // is not overlaid, so Wine sees no base prefix and cannot load kernel32. + // The real prefix path, not the home symlink, which a launch of another container repoints. The + // overlay only covers its upper dir and the aliases it was given; a spelling it does not know is + // not overlaid, so Wine sees no base prefix and cannot load kernel32. put("WINEPREFIX", ContainerOverlay.canonicalHostPath(File(container.rootDir, ".wine"))) put("FEX_APP_CONFIG_LOCATION", "$WIN_WORK_DIR\\") put("WINEDEBUG", "+err") diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index f960ce3dcf..830cdc4219 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -146,10 +146,13 @@ public BionicProgramLauncherComponent(ContentsManager contentsManager, ContentPr public void setPreUnpack(Runnable r) { this.preUnpack = r; } private BooleanSupplier launchCancelled; public void setLaunchCancelled(BooleanSupplier launchCancelled) { this.launchCancelled = launchCancelled; } + // False while the guest executable is a prerequisite installer that runs before the game. + private boolean gameStart = true; + public void setGameStart(boolean gameStart) { this.gameStart = gameStart; } @Override public void start() { synchronized (lock) { - FexCodeCaches.onGuestStarting(environment.getContext(), contentsManager, this); + FexCodeCaches.onGuestStarting(environment.getContext(), contentsManager, this, gameStart); stop(); if (wineInfo.isArm64EC()) extractEmulatorsDlls(); From be28b9941efa064a8af36bef73902480c9ddc770 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 14 Nov 2026 13:39:19 +0530 Subject: [PATCH 10/10] The install-time pool step can never fail an install Its setup (status text, install event) ran outside the step's error guard, so an exception there failed the install. All of it is guarded now. This is what failed three GOG download tests. --- .../shadercache/FossilizeManager.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt index ee3021737b..9503853261 100644 --- a/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt +++ b/app/src/main/java/app/gamenative/shadercache/FossilizeManager.kt @@ -392,13 +392,14 @@ object FossilizeManager { ) { if (!PrefManager.sharedShaderCache) return val key = ShaderCacheKey.storeGame(store, gameId)?.let(::ShaderCacheKey) ?: return - val generation = stagedPoolDrops[key.game] ?: 0 - stagedThisProcess.add(key.game) - val appContext = context.applicationContext - downloadInfo.setPostInstallSyncing(true) - downloadInfo.updateStatusMessage(appContext.getString(R.string.shader_cache_preparing)) - PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, true)) + // Everything below is inside the try: no part of this step may fail the install. try { + val generation = stagedPoolDrops[key.game] ?: 0 + stagedThisProcess.add(key.game) + val appContext = context.applicationContext ?: context + downloadInfo.setPostInstallSyncing(true) + downloadInfo.updateStatusMessage(appContext.getString(R.string.shader_cache_preparing)) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, true)) coroutineScope { val step = async(start = CoroutineStart.LAZY) { stagePool(appContext, downloadInfo, containerId, key, generation) } val running = stagingJobs.putIfAbsent(key.game, step) @@ -419,9 +420,11 @@ object FossilizeManager { } catch (t: Throwable) { Timber.tag(TAG).w(t, "Install-time pool download for %s failed", key) } finally { - downloadInfo.setPostInstallSyncing(false) - downloadInfo.updateStatusMessage(null) - PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, false)) + runCatching { + downloadInfo.setPostInstallSyncing(false) + downloadInfo.updateStatusMessage(null) + PluviaApp.events.emit(AndroidEvent.PostInstallSyncStatusChanged(eventGameId, false)) + }.onFailure { Timber.tag(TAG).w(it, "Could not clear the install status for %s", key) } } }