From f6ddff79bcc90da297bb7c90eb6c3bfbc5a99c42 Mon Sep 17 00:00:00 2001 From: jpolo1224 Date: Fri, 21 Aug 2026 10:30:15 -0400 Subject: [PATCH] LSFG: expose adaptive frame pacing, and remove the diagnostic tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pacer shipped in the previous commit but was inert: GSConfig.LsfgTargetRate defaulted to 0, which means "hold the multiplier fixed", and nothing in the Android settings could change it. So the port ran, but the specific problem it was brought over to solve — games that oscillate between 60 and 30fps on a 60Hz panel, where a fixed multiplier presents 120 then 60 and judders at every transition — was still there. Working but inert is the failure mode worth naming: nothing errors, the feature simply does not do the thing it was for. Plumbed through the usual twelve places (the field, INI read and write, the differs chain, toJson/fromJson, both per-game override paths, the reset list, the search index, the strings, and the two call sites), plus the two C++ ones in Pcsx2Config. Presented as a switch rather than a number. The pacer needs a concrete Hz, but picking one by hand is not a decision anyone can make usefully and the only sensible answer is the panel's own refresh rate — so the UI writes that when the toggle goes on, and 0 when it goes off. Off remains the default, so behaviour is unchanged until it is asked for. Also removes the step tracing added while chasing the Turnip crash. It did its job: five rounds of reading the code produced three wrong theories, and the trace produced the answer in two. The reasoning it uncovered is in the comments, which is where it belongs — the instrumentation is not. The new strings live in the github-only table, so the Play split still holds: playDebug has zero class files containing 'Lossless' or 'perf.lsfg', githubDebug has 2 and 4. --- pcsx2/Pcsx2Config.cpp | 2 + .../github/java/com/armsx2/i18n/I18nLsfg.kt | 2 + .../java/com/armsx2/ui/common/LsfgSection.kt | 37 ++++++++++++++----- .../ui/settingshub/SettingsSearchLsfg.kt | 1 + .../main/java/com/armsx2/config/Settings.kt | 19 ++++++++++ .../ui/emulation/EmulationMenuScreen.kt | 4 +- .../com/armsx2/ui/settings/PerformanceTab.kt | 4 +- .../ui/settingshub/SettingsResetFields.kt | 2 +- .../java/com/armsx2/ui/common/LsfgSection.kt | 6 ++- 9 files changed, 63 insertions(+), 14 deletions(-) diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index 2a415298fc..ce56caf5e2 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -932,6 +932,7 @@ bool Pcsx2Config::GSOptions::OptionsAreEqual(const GSOptions& right) const OpEqu(LsfgDllPath) && OpEqu(LsfgPerformance) && OpEqu(LsfgFlowScale) && + OpEqu(LsfgTargetRate) && OpEqu(CaptureContainer) && OpEqu(VideoCaptureCodec) && @@ -1211,6 +1212,7 @@ void Pcsx2Config::GSOptions::LoadSave(SettingsWrapper& wrap) SettingsWrapEntryEx(LsfgDllPath, "LsfgDllPath"); SettingsWrapEntryEx(LsfgPerformance, "LsfgPerformance"); SettingsWrapBitfieldEx(LsfgFlowScale, "LsfgFlowScale"); + SettingsWrapBitfieldEx(LsfgTargetRate, "LsfgTargetRate"); SettingsWrapEntryEx(CaptureContainer, "CaptureContainer"); SettingsWrapEntryEx(VideoCaptureCodec, "VideoCaptureCodec"); diff --git a/platforms/android/app/src/github/java/com/armsx2/i18n/I18nLsfg.kt b/platforms/android/app/src/github/java/com/armsx2/i18n/I18nLsfg.kt index 5f490ecbea..e4e1717290 100644 --- a/platforms/android/app/src/github/java/com/armsx2/i18n/I18nLsfg.kt +++ b/platforms/android/app/src/github/java/com/armsx2/i18n/I18nLsfg.kt @@ -13,6 +13,8 @@ internal val LSFG_EN: Map = mapOf( "perf.lsfg.multiplier.description" to "Frames displayed for each frame the emulator renders. Higher is smoother but adds latency and GPU load.", "perf.lsfg.performance.label" to "Performance Mode", "perf.lsfg.performance.description" to "Use the lighter 3.1p interpolation shaders. Cheaper on the GPU, slightly softer around fast motion. Ignored if your Lossless.dll is too old to include them.", + "perf.lsfg.adaptive.label" to "Adaptive frame pacing", + "perf.lsfg.adaptive.description" to "Vary how many frames are generated to hold a steady on-screen rate, instead of always multiplying by the same amount. Helps most in games that swing between 60 and 30fps, where a fixed multiplier makes every transition visible. Targets your display\u2019s refresh rate.", "perf.lsfg.flowScale.label" to "Motion Detail", "perf.lsfg.flowScale.description" to "Resolution of the motion analysis, as a share of the displayed image. Lower is much cheaper and blurs fine detail in the generated frames.", "perf.lsfg.dll.label" to "Lossless.dll", diff --git a/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt b/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt index 034f132d37..ff7a875836 100644 --- a/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt +++ b/platforms/android/app/src/github/java/com/armsx2/ui/common/LsfgSection.kt @@ -1,6 +1,7 @@ package com.armsx2.ui.common import android.content.Context +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke @@ -109,7 +110,8 @@ fun LsfgSection( dllPath: String, performance: Boolean, flowScale: Int, - onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int) -> Unit, + targetRate: Int, + onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int, targetRate: Int) -> Unit, ) { if (!BuildConfig.LSFG) return @@ -148,7 +150,7 @@ fun LsfgSection( } else { importError = null path = target.absolutePath - onChange(enabled, multiplier, path, performance, flowScale) + onChange(enabled, multiplier, path, performance, flowScale, targetRate) } } @@ -160,7 +162,7 @@ fun LsfgSection( // The requirements dialog fires on the way ON only, and BEFORE the toggle commits. // Turning something on and then being told it cannot work is the shape of this that // wastes the user's time; being told what it needs first is the shape that does not. - if (on) showRequirements = true else onChange(false, multiplier, path, performance, flowScale) + if (on) showRequirements = true else onChange(false, multiplier, path, performance, flowScale, targetRate) } if (enabled) { @@ -170,14 +172,30 @@ fun LsfgSection( options = listOf("x2", "x3", "x4"), selectedIndex = (multiplier - 2).coerceIn(0, 2), description = str("perf.lsfg.multiplier.description"), - ) { index -> onChange(enabled, index + 2, path, performance, flowScale) } + ) { index -> onChange(enabled, index + 2, path, performance, flowScale, targetRate) } + + SettingsDivider() + // Adaptive pacing. Stored as a concrete Hz because the native pacer needs a number, but + // presented as a switch: picking a target rate by hand is not a decision anyone can make + // usefully, and the only sensible answer is the panel's own refresh rate. + ToggleRow( + label = str("perf.lsfg.adaptive.label"), + value = targetRate > 0, + description = str("perf.lsfg.adaptive.description"), + ) { on -> + val hz = if (!on) 0 else runCatching { + val d = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) context.display else null + (d?.refreshRate ?: 60f).toInt().coerceIn(30, 480) + }.getOrDefault(60) + onChange(enabled, multiplier, path, performance, flowScale, hz) + } SettingsDivider() ToggleRow( label = str("perf.lsfg.performance.label"), value = performance, description = str("perf.lsfg.performance.description"), - ) { on -> onChange(enabled, multiplier, path, on, flowScale) } + ) { on -> onChange(enabled, multiplier, path, on, flowScale, targetRate) } SettingsDivider() // A percentage, not the divisor the library takes — the native side inverts it. Presented @@ -190,7 +208,7 @@ fun LsfgSection( max = 100, description = str("perf.lsfg.flowScale.description"), valueFormatter = { "$it%" }, - ) { value -> onChange(enabled, multiplier, path, performance, value) } + ) { value -> onChange(enabled, multiplier, path, performance, value, targetRate) } SettingsDivider() LsfgDllRow(path, importError) { picker.launch(arrayOf("*/*")) } @@ -214,7 +232,7 @@ fun LsfgSection( onDismiss = { showRequirements = false }, onAccept = { showRequirements = false - onChange(true, multiplier, path, performance, flowScale) + onChange(true, multiplier, path, performance, flowScale, targetRate) // Straight into the picker when there is nothing to run against — the first // thing the dialog just asked for is the file, so asking for it is the next // step rather than a second row to go and find. @@ -323,10 +341,11 @@ fun LsfgEmulationCard( dllPath: String, performance: Boolean, flowScale: Int, - onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int) -> Unit, + targetRate: Int, + onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int, targetRate: Int) -> Unit, ) { if (!BuildConfig.LSFG) return com.armsx2.ui.emulation.SectionCard(str("perf.lsfg.label")) { - LsfgSection(enabled, multiplier, dllPath, performance, flowScale, onChange) + LsfgSection(enabled, multiplier, dllPath, performance, flowScale, targetRate, onChange) } } diff --git a/platforms/android/app/src/github/java/com/armsx2/ui/settingshub/SettingsSearchLsfg.kt b/platforms/android/app/src/github/java/com/armsx2/ui/settingshub/SettingsSearchLsfg.kt index df0da1f2f3..2faebdac94 100644 --- a/platforms/android/app/src/github/java/com/armsx2/ui/settingshub/SettingsSearchLsfg.kt +++ b/platforms/android/app/src/github/java/com/armsx2/ui/settingshub/SettingsSearchLsfg.kt @@ -7,6 +7,7 @@ internal val LSFG_SEARCH_INDEX: List = listOf( SettingsSearchEntry("perf.lsfg.label", true, SettingsCategory.Performance), SettingsSearchEntry("perf.lsfg.multiplier.label", true, SettingsCategory.Performance), SettingsSearchEntry("perf.lsfg.performance.label", true, SettingsCategory.Performance), + SettingsSearchEntry("perf.lsfg.adaptive.label", true, SettingsCategory.Performance), SettingsSearchEntry("perf.lsfg.flowScale.label", true, SettingsCategory.Performance), SettingsSearchEntry("perf.lsfg.dll.label", true, SettingsCategory.Performance), ) diff --git a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt index 03be537647..95f24efbb2 100644 --- a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt @@ -592,6 +592,18 @@ data class Settings( * image (25..100). Lower is cheaper and blurrier. The native side inverts it: the library * takes a divisor, so 25% becomes 4.0. See GSLsfg.cpp. */ val lsfgFlowScale: Int = 100, + /** EmuCore/GS/LsfgTargetRate — target OUTPUT rate in Hz for the adaptive pacer; 0 holds + * [lsfgMultiplier] fixed. + * + * A fixed multiplier is the wrong shape for a game that oscillates between 60 and 30fps on + * a 60Hz panel: at x2 it presents 120 then 60, and every transition reads as judder. Given + * a target the pacer varies the generation count instead — two interpolated frames while + * the game runs at 30, one while it runs at 60 — so the presented rate stays put while the + * rendered rate moves underneath it. + * + * Stored as a concrete Hz rather than an on/off flag because the native pacer needs a + * number; the UI writes the panel's refresh rate when the user turns it on. */ + val lsfgTargetRate: Int = 0, /** Tweaked shader parameters, as `preset path -> (parameter name -> value)`. * * Sparse: a parameter the user hasn't touched is simply absent, and the author's own @@ -1203,6 +1215,7 @@ data class Settings( lsfgDllPath = strAt("EmuCore/GS/LsfgDllPath") ?: this.lsfgDllPath, lsfgPerformance = boolAt("EmuCore/GS/LsfgPerformance") ?: this.lsfgPerformance, lsfgFlowScale = intAt("EmuCore/GS/LsfgFlowScale") ?: this.lsfgFlowScale, + lsfgTargetRate = intAt("EmuCore/GS/LsfgTargetRate") ?: this.lsfgTargetRate, shaderChainParams = strAt("EmuCore/GS/ShaderChainParams")?.let { raw -> // Hand-editable file, so a malformed blob is a real possibility: keep the // rest of the recovered settings rather than throwing the lot away. @@ -1409,6 +1422,7 @@ data class Settings( // Clamped to the same 25..100 the native side enforces. A value outside it would be // coerced there anyway, and the two disagreeing is how a slider ends up looking stuck. put("EmuCore/GS", "LsfgFlowScale", "int", lsfgFlowScale.coerceIn(25, 100).toString()) + put("EmuCore/GS", "LsfgTargetRate", "int", lsfgTargetRate.coerceIn(0, 1000).toString()) // Parameter overrides, as one opaque JSON blob. Nothing in emucore reads this key — // there is no GSConfig field behind it, and the live values reach the renderer via // the push below, not through here. It is written so the map survives the same @@ -1598,6 +1612,7 @@ data class Settings( lsfgDllPath != other.lsfgDllPath || lsfgPerformance != other.lsfgPerformance || lsfgFlowScale != other.lsfgFlowScale || + lsfgTargetRate != other.lsfgTargetRate || casMode != other.casMode || casSharpness != other.casSharpness || upscaler != other.upscaler || @@ -1823,6 +1838,7 @@ data class Settings( put("lsfgDllPath", lsfgDllPath) put("lsfgPerformance", lsfgPerformance) put("lsfgFlowScale", lsfgFlowScale) + put("lsfgTargetRate", lsfgTargetRate) put("casMode", casMode) put("casSharpness", casSharpness) put("upscaler", upscaler) @@ -2109,6 +2125,7 @@ data class Settings( lsfgDllPath = json.optString("lsfgDllPath", def.lsfgDllPath), lsfgPerformance = json.optBoolean("lsfgPerformance", def.lsfgPerformance), lsfgFlowScale = json.optInt("lsfgFlowScale", def.lsfgFlowScale), + lsfgTargetRate = json.optInt("lsfgTargetRate", def.lsfgTargetRate), casMode = json.optInt("casMode", def.casMode), casSharpness = json.optInt("casSharpness", def.casSharpness), upscaler = json.optInt("upscaler", def.upscaler), @@ -2353,6 +2370,7 @@ data class Settings( if (current.lsfgDllPath != base.lsfgDllPath) j.put("lsfgDllPath", current.lsfgDllPath) if (current.lsfgPerformance != base.lsfgPerformance) j.put("lsfgPerformance", current.lsfgPerformance) if (current.lsfgFlowScale != base.lsfgFlowScale) j.put("lsfgFlowScale", current.lsfgFlowScale) + if (current.lsfgTargetRate != base.lsfgTargetRate) j.put("lsfgTargetRate", current.lsfgTargetRate) if (current.casMode != base.casMode) j.put("casMode", current.casMode) if (current.casSharpness != base.casSharpness) j.put("casSharpness", current.casSharpness) if (current.upscaler != base.upscaler) j.put("upscaler", current.upscaler) @@ -2614,6 +2632,7 @@ data class Settings( lsfgDllPath = if (overrides.has("lsfgDllPath")) overrides.getString("lsfgDllPath") else base.lsfgDllPath, lsfgPerformance = if (overrides.has("lsfgPerformance")) overrides.getBoolean("lsfgPerformance") else base.lsfgPerformance, lsfgFlowScale = if (overrides.has("lsfgFlowScale")) overrides.getInt("lsfgFlowScale") else base.lsfgFlowScale, + lsfgTargetRate = if (overrides.has("lsfgTargetRate")) overrides.getInt("lsfgTargetRate") else base.lsfgTargetRate, casMode = if (overrides.has("casMode")) overrides.getInt("casMode") else base.casMode, casSharpness = if (overrides.has("casSharpness")) overrides.getInt("casSharpness") else base.casSharpness, upscaler = if (overrides.has("upscaler")) overrides.getInt("upscaler") else base.upscaler, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt index 3570504004..307b8ee579 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt @@ -1109,7 +1109,8 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen dllPath = settings.lsfgDllPath, performance = settings.lsfgPerformance, flowScale = settings.lsfgFlowScale, - ) { on, mult, dll, perf, flow -> + targetRate = settings.lsfgTargetRate, + ) { on, mult, dll, perf, flow, target -> viewModel.updateSettings { it.copy( lsfgEnabled = on, @@ -1117,6 +1118,7 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen lsfgDllPath = dll, lsfgPerformance = perf, lsfgFlowScale = flow, + lsfgTargetRate = target, ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt index 6b228f8fd4..a0c0fbbb0d 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PerformanceTab.kt @@ -354,13 +354,15 @@ fun PerformanceTab(state: MutableState) { dllPath = s.lsfgDllPath, performance = s.lsfgPerformance, flowScale = s.lsfgFlowScale, - ) { on, mult, dll, perf, flow -> + targetRate = s.lsfgTargetRate, + ) { on, mult, dll, perf, flow, target -> apply(s.copy( lsfgEnabled = on, lsfgMultiplier = mult, lsfgDllPath = dll, lsfgPerformance = perf, lsfgFlowScale = flow, + lsfgTargetRate = target, )) } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt index a29aa8f4bf..071a766b6f 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsResetFields.kt @@ -33,7 +33,7 @@ internal val SETTINGS_CATEGORY_FIELDS: Map> = map // LsfgSection lives on this tab. lsfgDllPath is deliberately NOT here: Reset restores // settings, and forgetting which file the user imported is not a setting being restored, // it is making them go and find their Lossless.dll again. - "lsfgEnabled", "lsfgMultiplier", "lsfgPerformance", "lsfgFlowScale", + "lsfgEnabled", "lsfgMultiplier", "lsfgPerformance", "lsfgFlowScale", "lsfgTargetRate", ), // RendererTab.kt SettingsCategory.Graphics to listOf( diff --git a/platforms/android/app/src/play/java/com/armsx2/ui/common/LsfgSection.kt b/platforms/android/app/src/play/java/com/armsx2/ui/common/LsfgSection.kt index 612bca813c..ce032f8809 100644 --- a/platforms/android/app/src/play/java/com/armsx2/ui/common/LsfgSection.kt +++ b/platforms/android/app/src/play/java/com/armsx2/ui/common/LsfgSection.kt @@ -19,7 +19,8 @@ fun LsfgSection( dllPath: String, performance: Boolean, flowScale: Int, - onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int) -> Unit, + targetRate: Int, + onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int, targetRate: Int) -> Unit, ) { } @@ -31,6 +32,7 @@ fun LsfgEmulationCard( dllPath: String, performance: Boolean, flowScale: Int, - onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int) -> Unit, + targetRate: Int, + onChange: (enabled: Boolean, multiplier: Int, dllPath: String, performance: Boolean, flowScale: Int, targetRate: Int) -> Unit, ) { }