Skip to content

Commit ff80499

Browse files
[Agent] feat: ScalingMode enum — Phase 1 of Screen Scaling standardization (#3616)
* feat: introduce ScalingMode enum replacing nativeScale+integerScale booleans Adds ScalingMode (aspectFit, aspectFill, stretch, integerScale, nativeResolution) as the unified video scaling setting for all native PVCore-based renderers. Both Metal and GL renderers now use scalingMode for layout, enabling the previously-missing stretch and aspectFill modes. Legacy booleans kept (deprecated) with automatic migration on first launch. Part of #2673 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: self-review — setter, CustomStringConvertible, UIKit picker row, tests, changelog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(settings): guard .navigationLink pickerStyle behind !os(tvOS) NavigationLinkPickerStyle is unavailable on tvOS; use .automatic there. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: gate ScalingMode renderer paths behind scalingModeRenderer feature flag Add scalingModeRenderer feature flag (disabled by default) so both PVMetalViewController and PVGLViewController fall back to the legacy nativeScaleEnabled/integerScaleEnabled boolean paths when the flag is off. New ScalingMode paths (Stretch, Aspect Fill, etc.) activate only when the flag is toggled on in Settings > Advanced > Feature Flags, allowing Phase 1 to merge safely in parallel with active releases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused deviceScale variable in DeltaSkinDefaults Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf: cache scalingModeRenderer feature flag in GL/Metal view controllers Replace repeated PVFeatureFlags.shared.isEnabled(.scalingModeRenderer) calls in render loops with a single lazy-initialized stored property. The flag is read once on first access and reused for the session lifetime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d189f51 commit ff80499

File tree

13 files changed

+507
-70
lines changed

13 files changed

+507
-70
lines changed

.changelog/3616.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### Added
2+
- **ScalingMode Enum** — Introduces `ScalingMode` (Aspect Fit, Aspect Fill, Stretch, Integer Scale, Native Resolution) to replace the legacy `nativeScaleEnabled`/`integerScaleEnabled` boolean pair, giving users a single unified video scaling picker.
3+
- **`scalingModeRenderer` Feature Flag** — Gates the new renderer code paths behind a feature flag (disabled by default). The legacy `nativeScaleEnabled`/`integerScaleEnabled` boolean paths remain active until the flag is enabled in Settings > Advanced > Feature Flags, allowing parallel release cycles without risk.
4+
5+
### Changed
6+
- **Metal & GL Renderers** — Both `PVMetalViewController` and `PVGLViewController` now contain the new `ScalingMode`-based layout paths, activated only when the `scalingModeRenderer` feature flag is on.
7+
- **Settings UI** — The Native Resolution and Integer Scaling toggles in the Video settings section are replaced by a single Scaling Mode picker (UI change is independent of the renderer feature flag).

Cores/Mupen64Plus/Sources/PVMupen/MupenGameCore+Core.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import GLKit
3535
#if os(tvOS)
3636
let RESIZE_TO_FULLSCREEN: Bool = true
3737
#else
38-
let RESIZE_TO_FULLSCREEN: Bool = !Defaults[.nativeScaleEnabled]
38+
let RESIZE_TO_FULLSCREEN: Bool = Defaults[.scalingMode] != .nativeResolution
3939
#endif
4040

4141
extension m64p_core_param: @retroactive Hashable, @retroactive Equatable, @retroactive Codable {

PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ public enum PVFeature: String, CaseIterable, Sendable {
102102
/// into those apps via deep links, and querying their game libraries.
103103
/// Disabled by default while the feature is being validated against each app's current URL scheme.
104104
case thirdPartyEcosystemIntegration = "thirdPartyEcosystemIntegration"
105+
/// Activates the new `ScalingMode`-enum-based rendering paths in PVGLViewController and
106+
/// PVMetalViewController. When disabled (default), the legacy `nativeScaleEnabled` /
107+
/// `integerScaleEnabled` boolean code paths are used so existing scaling behaviour is
108+
/// unchanged. Enable in Settings > Advanced > Feature Flags to test the new modes
109+
/// (Stretch, Aspect Fill, etc.) before they are promoted to default in a future release.
110+
case scalingModeRenderer = "scalingModeRenderer"
105111
}
106112

107113
/// Enum representing supported OS platforms for feature flag filtering
@@ -297,6 +303,11 @@ public struct FeatureFlag: Codable, Sendable {
297303
allowedPlatforms: ["ios"],
298304
description: "Explicit SRAM/battery save import and export in the game context menu. Export shares .sav/.srm/.ram files; import opens a document picker. iOS only (tvOS syncs saves via iCloud)."
299305
)
306+
307+
public static let scalingModeRenderer = FeatureFlag(
308+
enabled: false,
309+
description: "Activates the ScalingMode-enum renderer paths (Stretch, Aspect Fill, Integer Scale, Native Resolution). When disabled the legacy nativeScaleEnabled/integerScaleEnabled boolean code paths are used — existing scaling is unchanged. Enable in Settings > Advanced > Feature Flags to test new modes (Phase 2 of screen-scaling standardisation)."
310+
)
300311
}
301312

302313
/// Root structure for feature flags JSON

PVSettings/Sources/PVSettings/Settings/Model/PVSettingsModel.swift

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,24 @@ fileprivate var IsAppStore: Bool {
2323
// Video
2424
public
2525
extension Defaults.Keys {
26-
#if os(iOS) || os(watchOS) || targetEnvironment(macCatalyst)
26+
/// Primary scaling mode that controls how game video is sized to fit the display.
27+
/// This supersedes the legacy `nativeScaleEnabled` and `integerScaleEnabled` booleans.
28+
/// On first launch the value is migrated from any previously stored boolean flags.
29+
static let scalingMode: Key<ScalingMode> = {
30+
migrateScalingModeIfNeeded()
31+
return Key<ScalingMode>("scalingMode", default: .aspectFit)
32+
}()
33+
34+
/// Legacy: use `scalingMode` instead.
35+
/// Kept for backwards-compatibility; the renderers read `scalingMode` directly.
36+
@available(*, deprecated, renamed: "scalingMode", message: "Use Defaults[.scalingMode] == .nativeResolution")
2737
static let nativeScaleEnabled = Key<Bool>("nativeScaleEnabled", default: false)
28-
#else
29-
static let nativeScaleEnabled = Key<Bool>("nativeScaleEnabled", default: false)
30-
#endif
38+
3139
static let imageSmoothing = Key<Bool>("imageSmoothing", default: false)
3240

41+
/// Legacy: use `scalingMode` instead.
42+
/// Kept for backwards-compatibility; the renderers read `scalingMode` directly.
43+
@available(*, deprecated, renamed: "scalingMode", message: "Use Defaults[.scalingMode] == .integerScale")
3344
static let integerScaleEnabled = Key<Bool>("integerScaleEnabled", default: false)
3445

3546
/// How the game is presented when an external display (HDMI / USB-C / AirPlay) is connected.
@@ -833,6 +844,25 @@ internal func makeMigratingBoolKey(_ primaryKey: String, legacyKey: String, defa
833844
return Defaults.Key<Bool>(primaryKey, default: defaultValue)
834845
}
835846

847+
// MARK: ScalingMode Migration
848+
849+
/// Migrates the legacy `nativeScaleEnabled` and `integerScaleEnabled` boolean flags
850+
/// to the unified `scalingMode` key on first access.
851+
/// - `integerScaleEnabled = true` maps to `.integerScale` (takes precedence).
852+
/// - `nativeScaleEnabled = true` maps to `.nativeResolution`.
853+
/// - Both false (or not set) maps to the default `.aspectFit`.
854+
internal func migrateScalingModeIfNeeded(userDefaults: UserDefaults = .standard) {
855+
let scalingKey = "scalingMode"
856+
guard userDefaults.object(forKey: scalingKey) == nil else { return }
857+
858+
let integerScale = userDefaults.bool(forKey: "integerScaleEnabled")
859+
let nativeScale = userDefaults.bool(forKey: "nativeScaleEnabled")
860+
let mode = ScalingMode.fromLegacy(nativeScale: nativeScale, integerScale: integerScale)
861+
if mode != .aspectFit {
862+
userDefaults.set(mode.rawValue, forKey: scalingKey)
863+
}
864+
}
865+
836866
// MARK: Beta Options
837867
public extension Defaults.Keys {
838868
#if os(macOS) || targetEnvironment(macCatalyst) || os(visionOS)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//
2+
// ScalingMode.swift
3+
// PVSettings
4+
//
5+
// Created by Provenance Emu on 2026-04-01.
6+
// Copyright © 2026 Provenance Emu. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import Defaults
11+
12+
/// Controls how game video output is scaled to fit the display.
13+
///
14+
/// This replaces the legacy `nativeScaleEnabled` + `integerScaleEnabled` boolean pair
15+
/// with a single composable enum that covers all supported rendering modes.
16+
///
17+
/// ## Mode Summary
18+
/// | Mode | AR Preserved | Fills Screen | Notes |
19+
/// |---|---|---|---|
20+
/// | `.aspectFit` | ✅ | ❌ (pillarbox/letterbox) | Default |
21+
/// | `.aspectFill` | ✅ | ✅ (may crop edges) | |
22+
/// | `.stretch` | ❌ | ✅ | |
23+
/// | `.integerScale` | ✅ | ❌ | Pixel-perfect |
24+
/// | `.nativeResolution` | ✅ | ❌ | 1:1 pixels |
25+
///
26+
/// ## RetroArch / libretro Notes
27+
/// libretro cores report geometry via `retro_game_geometry.aspect_ratio` (float ≤ 0 means
28+
/// use `base_width/base_height`). The renderer updates on `RETRO_ENVIRONMENT_SET_GEOMETRY (37)`
29+
/// or `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO (33)`. For per-core aspect ratio overrides
30+
/// the relevant core options keys are:
31+
/// - **Mupen64Plus**: `mupen64plus-aspect` → `4:3`, `16:9`, `stretch`
32+
/// - **Dolphin**: `dolphin_aspect_ratio` → `auto`, `force_43`, `force_169`, `stretch`
33+
/// - **PPSSPP**: `ppsspp_stretch` → `false/true`
34+
/// - **Flycast**: `flycast_widescreen_hack` → `disabled/enabled`
35+
/// - **DuckStation**: `duckstation_GPU.WidescreenHack` → `false/true`
36+
/// - **Gearcoleco**: `gearcoleco_aspect_ratio` → `1:1 PAR`, `4:3 DAR`, `16:9 DAR`
37+
/// - **Genesis Plus GX**: `genesis_plus_gx_aspect_ratio` → `auto`, `NTSC`, `PAL`
38+
/// - **Mednafen/Beetle PSX**: `beetle_psx_widescreen_hack` → `disabled/enabled`
39+
/// These are passed as `RETRO_ENVIRONMENT_GET_VARIABLE` options and can be set via
40+
/// `RETRO_ENVIRONMENT_SET_VARIABLES` callbacks before `retro_load_game`.
41+
public enum ScalingMode: String, Codable, Equatable, Hashable,
42+
UserDefaultsRepresentable, Defaults.Serializable, CaseIterable, Sendable,
43+
CustomStringConvertible {
44+
45+
/// Scale to fit within the display bounds while preserving the core's reported aspect ratio.
46+
/// Unused screen area shows the background (pillarboxed or letterboxed).
47+
/// This is the historical default behavior.
48+
case aspectFit = "aspectFit"
49+
50+
/// Scale to fill the entire display while preserving the core's reported aspect ratio.
51+
/// Content that falls outside the display bounds is cropped.
52+
case aspectFill = "aspectFill"
53+
54+
/// Scale to fill the entire display, ignoring the core's reported aspect ratio.
55+
/// Distortion may be visible, especially on square-pixel systems.
56+
case stretch = "stretch"
57+
58+
/// Scale to the nearest integer multiple of the core's native output resolution.
59+
/// Preserves pixel sharpness at the cost of unused screen border.
60+
/// Equivalent to the legacy `integerScaleEnabled` setting.
61+
case integerScale = "integerScale"
62+
63+
/// Render at the core's exact native pixel resolution (1:1 pixel mapping).
64+
/// Content will be small on high-DPI screens but pixel-perfect.
65+
/// Equivalent to the legacy `nativeScaleEnabled` setting.
66+
case nativeResolution = "nativeResolution"
67+
68+
// MARK: - Display Metadata
69+
70+
public var displayName: String {
71+
switch self {
72+
case .aspectFit: return "Aspect Fit"
73+
case .aspectFill: return "Aspect Fill"
74+
case .stretch: return "Stretch"
75+
case .integerScale: return "Integer Scale"
76+
case .nativeResolution: return "Native Resolution"
77+
}
78+
}
79+
80+
public var subtitle: String {
81+
switch self {
82+
case .aspectFit:
83+
return "Letterbox / pillarbox — preserves aspect ratio"
84+
case .aspectFill:
85+
return "Fill screen — preserves aspect ratio, may crop edges"
86+
case .stretch:
87+
return "Fill screen completely — ignores aspect ratio"
88+
case .integerScale:
89+
return "Pixel-perfect integer multiple of native resolution"
90+
case .nativeResolution:
91+
return "1:1 pixel mapping — sharpest but may appear small"
92+
}
93+
}
94+
95+
public var symbolName: String {
96+
switch self {
97+
case .aspectFit: return "aspectratio"
98+
case .aspectFill: return "arrow.up.left.and.arrow.down.right"
99+
case .stretch: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
100+
case .integerScale: return "square.grid.2x2"
101+
case .nativeResolution: return "1.circle"
102+
}
103+
}
104+
105+
// MARK: - Legacy Compatibility
106+
107+
/// Derive a `ScalingMode` from the legacy pair of boolean settings.
108+
/// - Parameters:
109+
/// - nativeScale: Legacy `nativeScaleEnabled` value.
110+
/// - integerScale: Legacy `integerScaleEnabled` value.
111+
/// - Returns: The equivalent `ScalingMode`. `integerScale` takes precedence over `nativeScale`.
112+
public static func fromLegacy(nativeScale: Bool, integerScale: Bool) -> ScalingMode {
113+
if integerScale { return .integerScale }
114+
if nativeScale { return .nativeResolution }
115+
return .aspectFit
116+
}
117+
118+
/// Whether this mode requires the renderer to apply native DPI scaling to the Metal / GL view.
119+
public var requiresNativeScaleFactor: Bool {
120+
self == .nativeResolution
121+
}
122+
123+
/// Whether this mode snaps dimensions to integer multiples of the core's native resolution.
124+
public var usesIntegerSnapping: Bool {
125+
self == .integerScale
126+
}
127+
128+
public var description: String { displayName }
129+
}

PVSettings/Tests/PVSettingsTests/PVSettingsTests.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,3 +1775,99 @@ struct LightGunSettingsTests {
17751775
}
17761776
}
17771777

1778+
// MARK: - ScalingMode Tests
1779+
1780+
@Suite("ScalingMode")
1781+
struct ScalingModeTests {
1782+
1783+
@Test("scalingMode default is aspectFit")
1784+
func scalingModeDefault() {
1785+
Defaults.reset(.scalingMode)
1786+
#expect(Defaults[.scalingMode] == .aspectFit)
1787+
}
1788+
1789+
@Test("scalingMode round-trips through UserDefaults")
1790+
func scalingModeRoundTrip() {
1791+
for mode in ScalingMode.allCases {
1792+
Defaults[.scalingMode] = mode
1793+
#expect(Defaults[.scalingMode] == mode)
1794+
}
1795+
Defaults.reset(.scalingMode)
1796+
}
1797+
1798+
@Test("fromLegacy — integerScale takes precedence over nativeScale")
1799+
func fromLegacyIntegerScalePrecedence() {
1800+
let mode = ScalingMode.fromLegacy(nativeScale: true, integerScale: true)
1801+
#expect(mode == .integerScale)
1802+
}
1803+
1804+
@Test("fromLegacy — nativeResolution when only nativeScale is true")
1805+
func fromLegacyNativeScale() {
1806+
let mode = ScalingMode.fromLegacy(nativeScale: true, integerScale: false)
1807+
#expect(mode == .nativeResolution)
1808+
}
1809+
1810+
@Test("fromLegacy — aspectFit when both false")
1811+
func fromLegacyBothFalse() {
1812+
let mode = ScalingMode.fromLegacy(nativeScale: false, integerScale: false)
1813+
#expect(mode == .aspectFit)
1814+
}
1815+
1816+
@Test("requiresNativeScaleFactor only true for nativeResolution")
1817+
func requiresNativeScaleFactor() {
1818+
#expect(ScalingMode.nativeResolution.requiresNativeScaleFactor == true)
1819+
for mode in ScalingMode.allCases where mode != .nativeResolution {
1820+
#expect(mode.requiresNativeScaleFactor == false)
1821+
}
1822+
}
1823+
1824+
@Test("usesIntegerSnapping only true for integerScale")
1825+
func usesIntegerSnapping() {
1826+
#expect(ScalingMode.integerScale.usesIntegerSnapping == true)
1827+
for mode in ScalingMode.allCases where mode != .integerScale {
1828+
#expect(mode.usesIntegerSnapping == false)
1829+
}
1830+
}
1831+
1832+
@Test("migration — no-op when scalingMode already set")
1833+
func migrationNoOpWhenAlreadySet() {
1834+
let ud = UserDefaults(suiteName: "test.scalingmode.noop")!
1835+
ud.set(ScalingMode.stretch.rawValue, forKey: "scalingMode")
1836+
migrateScalingModeIfNeeded(userDefaults: ud)
1837+
#expect(ud.string(forKey: "scalingMode") == ScalingMode.stretch.rawValue)
1838+
ud.removePersistentDomain(forName: "test.scalingmode.noop")
1839+
}
1840+
1841+
@Test("migration — maps integerScaleEnabled=true to .integerScale")
1842+
func migrationIntegerScale() {
1843+
let ud = UserDefaults(suiteName: "test.scalingmode.integer")!
1844+
ud.removeObject(forKey: "scalingMode")
1845+
ud.set(true, forKey: "integerScaleEnabled")
1846+
ud.set(false, forKey: "nativeScaleEnabled")
1847+
migrateScalingModeIfNeeded(userDefaults: ud)
1848+
#expect(ud.string(forKey: "scalingMode") == ScalingMode.integerScale.rawValue)
1849+
ud.removePersistentDomain(forName: "test.scalingmode.integer")
1850+
}
1851+
1852+
@Test("migration — maps nativeScaleEnabled=true to .nativeResolution")
1853+
func migrationNativeResolution() {
1854+
let ud = UserDefaults(suiteName: "test.scalingmode.native")!
1855+
ud.removeObject(forKey: "scalingMode")
1856+
ud.set(false, forKey: "integerScaleEnabled")
1857+
ud.set(true, forKey: "nativeScaleEnabled")
1858+
migrateScalingModeIfNeeded(userDefaults: ud)
1859+
#expect(ud.string(forKey: "scalingMode") == ScalingMode.nativeResolution.rawValue)
1860+
ud.removePersistentDomain(forName: "test.scalingmode.native")
1861+
}
1862+
1863+
@Test("migration — both false does NOT write to UserDefaults (stays at default)")
1864+
func migrationBothFalseSkipsWrite() {
1865+
let ud = UserDefaults(suiteName: "test.scalingmode.default")!
1866+
ud.removeObject(forKey: "scalingMode")
1867+
ud.set(false, forKey: "integerScaleEnabled")
1868+
ud.set(false, forKey: "nativeScaleEnabled")
1869+
migrateScalingModeIfNeeded(userDefaults: ud)
1870+
#expect(ud.object(forKey: "scalingMode") == nil)
1871+
ud.removePersistentDomain(forName: "test.scalingmode.default")
1872+
}
1873+
}

PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1853,8 +1853,7 @@ private struct VideoSection: View {
18531853
@Default(.multiSampling) var multiSampling
18541854
@Default(.imageSmoothing) var imageSmoothing
18551855
@Default(.showFPSCount) var showFPSCount
1856-
@Default(.nativeScaleEnabled) var nativeScaleEnabled
1857-
@Default(.integerScaleEnabled) var integerScaleEnabled
1856+
@Default(.scalingMode) var scalingMode
18581857
@Default(.vsyncEnabled) var vsyncEnabled
18591858

18601859
var body: some View {
@@ -1877,18 +1876,22 @@ private struct VideoSection: View {
18771876
icon: .sfSymbol("square.stack.3d.up"),
18781877
showChevron: false)
18791878
}
1880-
ThemedToggle(isOn: $nativeScaleEnabled) {
1881-
SettingsRow(title: "Native Resolution",
1882-
subtitle: nativeScaleEnabled ? "Use the original console's resolution." : "Scale to fit the window.",
1883-
icon: .sfSymbol("arrow.up.left.and.arrow.down.right"),
1884-
showChevron: false)
1885-
}
1886-
ThemedToggle(isOn: $integerScaleEnabled) {
1887-
SettingsRow(title: "Integer Scaling",
1888-
subtitle: "Scale by whole numbers only for pixel-perfect display.",
1889-
icon: .sfSymbol("square.grid.4x3.fill"),
1879+
Picker(selection: $scalingMode) {
1880+
ForEach(ScalingMode.allCases, id: \.self) { mode in
1881+
Label(mode.displayName, systemImage: mode.symbolName)
1882+
.tag(mode)
1883+
}
1884+
} label: {
1885+
SettingsRow(title: "Scaling Mode",
1886+
subtitle: scalingMode.subtitle,
1887+
icon: .sfSymbol(scalingMode.symbolName),
18901888
showChevron: false)
18911889
}
1890+
#if os(tvOS)
1891+
.pickerStyle(.automatic)
1892+
#else
1893+
.pickerStyle(.navigationLink)
1894+
#endif
18921895
ThemedToggle(isOn: $imageSmoothing) {
18931896
SettingsRow(title: "Image Smoothing",
18941897
subtitle: "Smooth scaled graphics. Off for sharp pixels.",

0 commit comments

Comments
 (0)