LSFG: expose adaptive frame pacing, and remove the diagnostic tracing

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.
This commit is contained in:
jpolo1224
2026-08-21 10:30:15 -04:00
parent 2080bd1c44
commit f6ddff79bc
9 changed files with 63 additions and 14 deletions
+2
View File
@@ -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");
@@ -13,6 +13,8 @@ internal val LSFG_EN: Map<String, String> = 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",
@@ -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)
}
}
@@ -7,6 +7,7 @@ internal val LSFG_SEARCH_INDEX: List<SettingsSearchEntry> = 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),
)
@@ -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,
@@ -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,
)
}
}
@@ -354,13 +354,15 @@ fun PerformanceTab(state: MutableState<Settings>) {
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,
))
}
@@ -33,7 +33,7 @@ internal val SETTINGS_CATEGORY_FIELDS: Map<SettingsCategory, List<String>> = 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(
@@ -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,
) {
}