Skip to content
Merged
7 changes: 7 additions & 0 deletions .changelog/3616.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Added
- **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.
- **`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.

### Changed
- **Metal & GL Renderers** — Both `PVMetalViewController` and `PVGLViewController` now contain the new `ScalingMode`-based layout paths, activated only when the `scalingModeRenderer` feature flag is on.
- **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).
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import GLKit
#if os(tvOS)
let RESIZE_TO_FULLSCREEN: Bool = true
#else
let RESIZE_TO_FULLSCREEN: Bool = !Defaults[.nativeScaleEnabled]
let RESIZE_TO_FULLSCREEN: Bool = Defaults[.scalingMode] != .nativeResolution
#endif

extension m64p_core_param: @retroactive Hashable, @retroactive Equatable, @retroactive Codable {
Expand Down
11 changes: 11 additions & 0 deletions PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

/// Enum representing all available feature flags
public enum PVFeature: String, CaseIterable, Sendable {
case inAppFreeROMs = "inAppFreeROMs"

Check warning on line 22 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

String enum values can be omitted when they are equal to the enumcase name (redundant_string_enum_value)
case romPathMigrator = "romPathMigrator"
case cheatsUseSwiftUI = "cheatsUseSwiftUI"
case cheatsOnlineLookup = "cheatsOnlineLookup"
Expand All @@ -32,7 +32,7 @@
/// On iOS, enable via the PVFeatureFlags debug-override UI (accessible on all build
/// types; hidden behind a cheat code on App Store builds).
case dynamicLibretroScanner = "dynamicLibretroScanner"
/// Enables the experimental tile/grid based pause menu overlay that floats over the

Check warning on line 35 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Tiles are deprecated in favor of Frame (tiles_deprecated)
/// game screen instead of the classic full-panel tab/list menu. Disabled by default.
case pauseTileMenu = "pauseTileMenu"
/// Enables tap-to-remap UX in the button remapping settings: instead of selecting a
Expand Down Expand Up @@ -87,7 +87,7 @@
/// are hidden from the selection UI and excluded from automatic skin resolution.
/// Disabled by default while the feature is under development.
case caseCompanionSkins = "caseCompanionSkins"
/// Shows the AirPlay route-picker button in the pause menu (both tile and retro-menu styles).

Check warning on line 90 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Tiles are deprecated in favor of Frame (tiles_deprecated)
/// Currently only audio AirPlay is supported; video mirroring to Apple TV is not yet
/// implemented. Disabled by default until full video AirPlay support lands.
case airPlayMenu = "airPlayMenu"
Expand All @@ -102,6 +102,12 @@
/// into those apps via deep links, and querying their game libraries.
/// Disabled by default while the feature is being validated against each app's current URL scheme.
case thirdPartyEcosystemIntegration = "thirdPartyEcosystemIntegration"
/// Activates the new `ScalingMode`-enum-based rendering paths in PVGLViewController and
/// PVMetalViewController. When disabled (default), the legacy `nativeScaleEnabled` /
/// `integerScaleEnabled` boolean code paths are used so existing scaling behaviour is
/// unchanged. Enable in Settings > Advanced > Feature Flags to test the new modes
/// (Stretch, Aspect Fill, etc.) before they are promoted to default in a future release.
case scalingModeRenderer = "scalingModeRenderer"
}

/// Enum representing supported OS platforms for feature flag filtering
Expand Down Expand Up @@ -211,13 +217,13 @@
minVersion: "3.3.0",
allowedAppTypes: ["standard", "lite", "standard.appstore", "lite.appstore"],
allowedPlatforms: ["tvos"],
description: "Scans Frameworks/ at startup for bare libretro dylibs/frameworks and loads them via PVThinLibretroFrontend. On tvOS: enabled by default. On iOS: disabled by default but can be enabled in Settings > Advanced > Feature Flags."

Check warning on line 220 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 246 characters (line_length)
)

static let _pauseTileMenu: Bool = true
public static let pauseTileMenu = FeatureFlag(
enabled: _pauseTileMenu,
description: "Experimental tile/grid based pause menu overlay that floats over the game screen. Default is the classic tab/list menu."

Check warning on line 226 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Tiles are deprecated in favor of Frame (tiles_deprecated)
)

public static let tapToRemapUI = FeatureFlag(
Expand Down Expand Up @@ -256,7 +262,7 @@

public static let companionController = FeatureFlag(
enabled: false,
description: "Companion Controller overlay — use this device as a secondary controller for systems with non-standard input peripherals (trackball, numpad, Atari 5200). Disabled until DSU integration is complete."

Check warning on line 265 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 220 characters (line_length)
)

#if os(tvOS)
Expand All @@ -272,13 +278,13 @@
public static let skinButtonReposition = FeatureFlag(
enabled: false,
allowedPlatforms: ["ios"],
description: "Drag-to-reposition button layout editor for custom skins. Shows an 'Edit Layout' toolbar over the skin; users drag buttons to reposition them. Offsets persist per skin in UserDefaults. iOS only. Disabled by default — enable in Settings > Advanced > Feature Flags."

Check warning on line 281 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 286 characters (line_length)
)

public static let caseCompanionSkins = FeatureFlag(
enabled: false,
allowedPlatforms: ["ios"],
description: "Case-companion skins for phone-case controllers (Buppin, GameSir Pocket Taco, Soolra). Shows companion skins in the skin browser and includes them in automatic skin selection. iOS only. Disabled by default while under development."

Check warning on line 287 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 253 characters (line_length)
)

public static let airPlayMenu = FeatureFlag(
Expand All @@ -295,8 +301,13 @@
"lite.appstore"
],
allowedPlatforms: ["ios"],
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)."

Check warning on line 304 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 201 characters (line_length)
)

public static let scalingModeRenderer = FeatureFlag(
enabled: false,
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)."

Check warning on line 309 in PVFeatureFlags/Sources/PVFeatureFlags/PVFeatureFlags.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 200 characters or less; currently it has 360 characters (line_length)
)
}

/// Root structure for feature flags JSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ fileprivate var IsAppStore: Bool {
// Video
public
extension Defaults.Keys {
#if os(iOS) || os(watchOS) || targetEnvironment(macCatalyst)
/// Primary scaling mode that controls how game video is sized to fit the display.
/// This supersedes the legacy `nativeScaleEnabled` and `integerScaleEnabled` booleans.
/// On first launch the value is migrated from any previously stored boolean flags.
static let scalingMode: Key<ScalingMode> = {
migrateScalingModeIfNeeded()
return Key<ScalingMode>("scalingMode", default: .aspectFit)
}()

/// Legacy: use `scalingMode` instead.
/// Kept for backwards-compatibility; the renderers read `scalingMode` directly.
@available(*, deprecated, renamed: "scalingMode", message: "Use Defaults[.scalingMode] == .nativeResolution")
static let nativeScaleEnabled = Key<Bool>("nativeScaleEnabled", default: false)
#else
static let nativeScaleEnabled = Key<Bool>("nativeScaleEnabled", default: false)
#endif

static let imageSmoothing = Key<Bool>("imageSmoothing", default: false)

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

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

// MARK: ScalingMode Migration

/// Migrates the legacy `nativeScaleEnabled` and `integerScaleEnabled` boolean flags
/// to the unified `scalingMode` key on first access.
/// - `integerScaleEnabled = true` maps to `.integerScale` (takes precedence).
/// - `nativeScaleEnabled = true` maps to `.nativeResolution`.
/// - Both false (or not set) maps to the default `.aspectFit`.
internal func migrateScalingModeIfNeeded(userDefaults: UserDefaults = .standard) {
let scalingKey = "scalingMode"
guard userDefaults.object(forKey: scalingKey) == nil else { return }

let integerScale = userDefaults.bool(forKey: "integerScaleEnabled")
let nativeScale = userDefaults.bool(forKey: "nativeScaleEnabled")
let mode = ScalingMode.fromLegacy(nativeScale: nativeScale, integerScale: integerScale)
if mode != .aspectFit {
userDefaults.set(mode.rawValue, forKey: scalingKey)
}
}

// MARK: Beta Options
public extension Defaults.Keys {
#if os(macOS) || targetEnvironment(macCatalyst) || os(visionOS)
Expand Down
129 changes: 129 additions & 0 deletions PVSettings/Sources/PVSettings/Settings/Model/ScalingMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// ScalingMode.swift
// PVSettings
//
// Created by Provenance Emu on 2026-04-01.
// Copyright © 2026 Provenance Emu. All rights reserved.
//

import Foundation
import Defaults

/// Controls how game video output is scaled to fit the display.
///
/// This replaces the legacy `nativeScaleEnabled` + `integerScaleEnabled` boolean pair
/// with a single composable enum that covers all supported rendering modes.
///
/// ## Mode Summary
/// | Mode | AR Preserved | Fills Screen | Notes |
/// |---|---|---|---|
/// | `.aspectFit` | ✅ | ❌ (pillarbox/letterbox) | Default |
/// | `.aspectFill` | ✅ | ✅ (may crop edges) | |
/// | `.stretch` | ❌ | ✅ | |
/// | `.integerScale` | ✅ | ❌ | Pixel-perfect |
/// | `.nativeResolution` | ✅ | ❌ | 1:1 pixels |
///
/// ## RetroArch / libretro Notes
/// libretro cores report geometry via `retro_game_geometry.aspect_ratio` (float ≤ 0 means
/// use `base_width/base_height`). The renderer updates on `RETRO_ENVIRONMENT_SET_GEOMETRY (37)`
/// or `RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO (33)`. For per-core aspect ratio overrides
/// the relevant core options keys are:
/// - **Mupen64Plus**: `mupen64plus-aspect` → `4:3`, `16:9`, `stretch`
/// - **Dolphin**: `dolphin_aspect_ratio` → `auto`, `force_43`, `force_169`, `stretch`
/// - **PPSSPP**: `ppsspp_stretch` → `false/true`
/// - **Flycast**: `flycast_widescreen_hack` → `disabled/enabled`
/// - **DuckStation**: `duckstation_GPU.WidescreenHack` → `false/true`
/// - **Gearcoleco**: `gearcoleco_aspect_ratio` → `1:1 PAR`, `4:3 DAR`, `16:9 DAR`
/// - **Genesis Plus GX**: `genesis_plus_gx_aspect_ratio` → `auto`, `NTSC`, `PAL`
/// - **Mednafen/Beetle PSX**: `beetle_psx_widescreen_hack` → `disabled/enabled`
/// These are passed as `RETRO_ENVIRONMENT_GET_VARIABLE` options and can be set via
/// `RETRO_ENVIRONMENT_SET_VARIABLES` callbacks before `retro_load_game`.
public enum ScalingMode: String, Codable, Equatable, Hashable,
UserDefaultsRepresentable, Defaults.Serializable, CaseIterable, Sendable,
CustomStringConvertible {

/// Scale to fit within the display bounds while preserving the core's reported aspect ratio.
/// Unused screen area shows the background (pillarboxed or letterboxed).
/// This is the historical default behavior.
case aspectFit = "aspectFit"

/// Scale to fill the entire display while preserving the core's reported aspect ratio.
/// Content that falls outside the display bounds is cropped.
case aspectFill = "aspectFill"

/// Scale to fill the entire display, ignoring the core's reported aspect ratio.
/// Distortion may be visible, especially on square-pixel systems.
case stretch = "stretch"

/// Scale to the nearest integer multiple of the core's native output resolution.
/// Preserves pixel sharpness at the cost of unused screen border.
/// Equivalent to the legacy `integerScaleEnabled` setting.
case integerScale = "integerScale"

/// Render at the core's exact native pixel resolution (1:1 pixel mapping).
/// Content will be small on high-DPI screens but pixel-perfect.
/// Equivalent to the legacy `nativeScaleEnabled` setting.
case nativeResolution = "nativeResolution"

// MARK: - Display Metadata

public var displayName: String {
switch self {
case .aspectFit: return "Aspect Fit"
case .aspectFill: return "Aspect Fill"
case .stretch: return "Stretch"
case .integerScale: return "Integer Scale"
case .nativeResolution: return "Native Resolution"
}
}

public var subtitle: String {
switch self {
case .aspectFit:
return "Letterbox / pillarbox — preserves aspect ratio"
case .aspectFill:
return "Fill screen — preserves aspect ratio, may crop edges"
case .stretch:
return "Fill screen completely — ignores aspect ratio"
case .integerScale:
return "Pixel-perfect integer multiple of native resolution"
case .nativeResolution:
return "1:1 pixel mapping — sharpest but may appear small"
}
}

public var symbolName: String {
switch self {
case .aspectFit: return "aspectratio"
case .aspectFill: return "arrow.up.left.and.arrow.down.right"
case .stretch: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
case .integerScale: return "square.grid.2x2"
case .nativeResolution: return "1.circle"
}
}

// MARK: - Legacy Compatibility

/// Derive a `ScalingMode` from the legacy pair of boolean settings.
/// - Parameters:
/// - nativeScale: Legacy `nativeScaleEnabled` value.
/// - integerScale: Legacy `integerScaleEnabled` value.
/// - Returns: The equivalent `ScalingMode`. `integerScale` takes precedence over `nativeScale`.
public static func fromLegacy(nativeScale: Bool, integerScale: Bool) -> ScalingMode {
if integerScale { return .integerScale }
if nativeScale { return .nativeResolution }
return .aspectFit
}

/// Whether this mode requires the renderer to apply native DPI scaling to the Metal / GL view.
public var requiresNativeScaleFactor: Bool {
self == .nativeResolution
}

/// Whether this mode snaps dimensions to integer multiples of the core's native resolution.
public var usesIntegerSnapping: Bool {
self == .integerScale
}

public var description: String { displayName }
}
96 changes: 96 additions & 0 deletions PVSettings/Tests/PVSettingsTests/PVSettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1775,3 +1775,99 @@ struct LightGunSettingsTests {
}
}

// MARK: - ScalingMode Tests

@Suite("ScalingMode")
struct ScalingModeTests {

@Test("scalingMode default is aspectFit")
func scalingModeDefault() {
Defaults.reset(.scalingMode)
#expect(Defaults[.scalingMode] == .aspectFit)
}

@Test("scalingMode round-trips through UserDefaults")
func scalingModeRoundTrip() {
for mode in ScalingMode.allCases {
Defaults[.scalingMode] = mode
#expect(Defaults[.scalingMode] == mode)
}
Defaults.reset(.scalingMode)
}

@Test("fromLegacy — integerScale takes precedence over nativeScale")
func fromLegacyIntegerScalePrecedence() {
let mode = ScalingMode.fromLegacy(nativeScale: true, integerScale: true)
#expect(mode == .integerScale)
}

@Test("fromLegacy — nativeResolution when only nativeScale is true")
func fromLegacyNativeScale() {
let mode = ScalingMode.fromLegacy(nativeScale: true, integerScale: false)
#expect(mode == .nativeResolution)
}

@Test("fromLegacy — aspectFit when both false")
func fromLegacyBothFalse() {
let mode = ScalingMode.fromLegacy(nativeScale: false, integerScale: false)
#expect(mode == .aspectFit)
}

@Test("requiresNativeScaleFactor only true for nativeResolution")
func requiresNativeScaleFactor() {
#expect(ScalingMode.nativeResolution.requiresNativeScaleFactor == true)
for mode in ScalingMode.allCases where mode != .nativeResolution {
#expect(mode.requiresNativeScaleFactor == false)
}
}

@Test("usesIntegerSnapping only true for integerScale")
func usesIntegerSnapping() {
#expect(ScalingMode.integerScale.usesIntegerSnapping == true)
for mode in ScalingMode.allCases where mode != .integerScale {
#expect(mode.usesIntegerSnapping == false)
}
}

@Test("migration — no-op when scalingMode already set")
func migrationNoOpWhenAlreadySet() {
let ud = UserDefaults(suiteName: "test.scalingmode.noop")!
ud.set(ScalingMode.stretch.rawValue, forKey: "scalingMode")
migrateScalingModeIfNeeded(userDefaults: ud)
#expect(ud.string(forKey: "scalingMode") == ScalingMode.stretch.rawValue)
ud.removePersistentDomain(forName: "test.scalingmode.noop")
}

@Test("migration — maps integerScaleEnabled=true to .integerScale")
func migrationIntegerScale() {
let ud = UserDefaults(suiteName: "test.scalingmode.integer")!
ud.removeObject(forKey: "scalingMode")
ud.set(true, forKey: "integerScaleEnabled")
ud.set(false, forKey: "nativeScaleEnabled")
migrateScalingModeIfNeeded(userDefaults: ud)
#expect(ud.string(forKey: "scalingMode") == ScalingMode.integerScale.rawValue)
ud.removePersistentDomain(forName: "test.scalingmode.integer")
}

@Test("migration — maps nativeScaleEnabled=true to .nativeResolution")
func migrationNativeResolution() {
let ud = UserDefaults(suiteName: "test.scalingmode.native")!
ud.removeObject(forKey: "scalingMode")
ud.set(false, forKey: "integerScaleEnabled")
ud.set(true, forKey: "nativeScaleEnabled")
migrateScalingModeIfNeeded(userDefaults: ud)
#expect(ud.string(forKey: "scalingMode") == ScalingMode.nativeResolution.rawValue)
ud.removePersistentDomain(forName: "test.scalingmode.native")
}

@Test("migration — both false does NOT write to UserDefaults (stays at default)")
func migrationBothFalseSkipsWrite() {
let ud = UserDefaults(suiteName: "test.scalingmode.default")!
ud.removeObject(forKey: "scalingMode")
ud.set(false, forKey: "integerScaleEnabled")
ud.set(false, forKey: "nativeScaleEnabled")
migrateScalingModeIfNeeded(userDefaults: ud)
#expect(ud.object(forKey: "scalingMode") == nil)
ud.removePersistentDomain(forName: "test.scalingmode.default")
}
}
27 changes: 15 additions & 12 deletions PVUI/Sources/PVSwiftUI/Settings/SettingsSwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1853,8 +1853,7 @@ private struct VideoSection: View {
@Default(.multiSampling) var multiSampling
@Default(.imageSmoothing) var imageSmoothing
@Default(.showFPSCount) var showFPSCount
@Default(.nativeScaleEnabled) var nativeScaleEnabled
@Default(.integerScaleEnabled) var integerScaleEnabled
@Default(.scalingMode) var scalingMode
@Default(.vsyncEnabled) var vsyncEnabled

var body: some View {
Expand All @@ -1877,18 +1876,22 @@ private struct VideoSection: View {
icon: .sfSymbol("square.stack.3d.up"),
showChevron: false)
}
ThemedToggle(isOn: $nativeScaleEnabled) {
SettingsRow(title: "Native Resolution",
subtitle: nativeScaleEnabled ? "Use the original console's resolution." : "Scale to fit the window.",
icon: .sfSymbol("arrow.up.left.and.arrow.down.right"),
showChevron: false)
}
ThemedToggle(isOn: $integerScaleEnabled) {
SettingsRow(title: "Integer Scaling",
subtitle: "Scale by whole numbers only for pixel-perfect display.",
icon: .sfSymbol("square.grid.4x3.fill"),
Picker(selection: $scalingMode) {
ForEach(ScalingMode.allCases, id: \.self) { mode in
Label(mode.displayName, systemImage: mode.symbolName)
.tag(mode)
}
} label: {
SettingsRow(title: "Scaling Mode",
subtitle: scalingMode.subtitle,
icon: .sfSymbol(scalingMode.symbolName),
showChevron: false)
}
#if os(tvOS)
.pickerStyle(.automatic)
#else
.pickerStyle(.navigationLink)
#endif
ThemedToggle(isOn: $imageSmoothing) {
SettingsRow(title: "Image Smoothing",
subtitle: "Smooth scaled graphics. Off for sharp pixels.",
Expand Down
Loading
Loading