Skip to content

Commit 0210365

Browse files
github-actions[bot]claude
authored andcommitted
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>
1 parent 092e2f8 commit 0210365

File tree

4 files changed

+153
-79
lines changed

4 files changed

+153
-79
lines changed

.changelog/3616.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
### Added
22
- **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.
34

45
### Changed
5-
- **Metal & GL Renderers** — Both `PVMetalViewController` and `PVGLViewController` now derive layout dimensions from `ScalingMode`, adding support for Stretch and Aspect Fill modes that were previously impossible.
6-
- **Settings UI** — The Native Resolution and Integer Scaling toggles in the Video settings section are replaced by a single Scaling Mode picker.
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).

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

PVUI/Sources/PVUIBase/PVGLViewController/PVGLViewController.swift

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import MetalKit
3333
import PVLogging
3434
import Defaults
3535
import PVSettings
36+
import PVFeatureFlags
3637
import PVUIObjC
3738

3839
internal let SHADER_DIR = "GLES"
@@ -337,7 +338,9 @@ final class PVGLViewController: PVGPUViewController, PVRenderDelegate {
337338
break
338339
}
339340

340-
if Defaults[.scalingMode] == .nativeResolution {
341+
if (PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
342+
? Defaults[.scalingMode] == .nativeResolution
343+
: Defaults[.nativeScaleEnabled]) {
341344
let scale = UIScreen.main.scale
342345
if scale != 1 {
343346
view.layer.contentsScale = scale
@@ -465,59 +468,82 @@ final class PVGLViewController: PVGPUViewController, PVRenderDelegate {
465468
var width: CGFloat = 0
466469

467470
let scalingMode = renderSettings.scalingMode
468-
switch scalingMode {
469-
case .stretch:
470-
width = parentSize.width
471-
height = parentSize.height
471+
let useNewScalingRenderer = PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
472472

473-
case .aspectFill:
474-
if parentSize.width > parentSize.height {
475-
height = parentSize.height
476-
width = height * ratio
477-
if width < parentSize.width {
478-
width = parentSize.width
479-
height = width / ratio
480-
}
481-
} else {
473+
if useNewScalingRenderer {
474+
switch scalingMode {
475+
case .stretch:
482476
width = parentSize.width
483-
height = width / ratio
484-
if height < parentSize.height {
477+
height = parentSize.height
478+
479+
case .aspectFill:
480+
if parentSize.width > parentSize.height {
485481
height = parentSize.height
486482
width = height * ratio
483+
if width < parentSize.width {
484+
width = parentSize.width
485+
height = width / ratio
486+
}
487+
} else {
488+
width = parentSize.width
489+
height = width / ratio
490+
if height < parentSize.height {
491+
height = parentSize.height
492+
width = height * ratio
493+
}
487494
}
488-
}
489495

490-
case .nativeResolution:
491-
width = aspectSize.width
492-
height = aspectSize.height
496+
case .nativeResolution:
497+
width = aspectSize.width
498+
height = aspectSize.height
493499

494-
case .integerScale:
495-
if parentSize.width > parentSize.height {
496-
height = floor(parentSize.height / aspectSize.height) * aspectSize.height
497-
width = height * ratio
498-
if width > parentSize.width {
499-
width = parentSize.width
500+
case .integerScale:
501+
if parentSize.width > parentSize.height {
502+
height = floor(parentSize.height / aspectSize.height) * aspectSize.height
503+
width = height * ratio
504+
if width > parentSize.width {
505+
width = parentSize.width
506+
height = width / ratio
507+
}
508+
} else {
509+
width = floor(parentSize.width / aspectSize.width) * aspectSize.width
500510
height = width / ratio
511+
if height > parentSize.height {
512+
height = parentSize.height
513+
width = height * ratio
514+
}
501515
}
502-
} else {
503-
width = floor(parentSize.width / aspectSize.width) * aspectSize.width
504-
height = width / ratio
505-
if height > parentSize.height {
516+
517+
case .aspectFit:
518+
if parentSize.width > parentSize.height {
506519
height = parentSize.height
507520
width = height * ratio
521+
if width > parentSize.width {
522+
width = parentSize.width
523+
height = width / ratio
524+
}
525+
} else {
526+
width = parentSize.width
527+
height = width / ratio
528+
if height > parentSize.height {
529+
height = parentSize.height
530+
width = height * ratio
531+
}
508532
}
509533
}
510-
511-
case .aspectFit:
534+
} else {
535+
// Legacy layout: honours the old integerScaleEnabled / nativeScaleEnabled booleans.
512536
if parentSize.width > parentSize.height {
513-
height = parentSize.height
537+
height = Defaults[.integerScaleEnabled] ?
538+
floor(parentSize.height / aspectSize.height) * aspectSize.height : parentSize.height
514539
width = height * ratio
515540
if width > parentSize.width {
516541
width = parentSize.width
517542
height = width / ratio
518543
}
519544
} else {
520-
width = parentSize.width
545+
width = Defaults[.integerScaleEnabled] ?
546+
floor(parentSize.width / aspectSize.width) * aspectSize.width : parentSize.width
521547
height = width / ratio
522548
if height > parentSize.height {
523549
height = parentSize.height

PVUI/Sources/PVUIBase/PVGLViewController/PVMetalViewController.swift

Lines changed: 79 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import PVSupport
1515
import PVEmulatorCore
1616
import PVLogging
1717
import PVSettings
18+
import PVFeatureFlags
1819
import PVShaders
1920
import simd
2021

@@ -787,66 +788,94 @@ class PVMetalViewController : PVGPUViewController, PVRenderDelegate, MTKViewDele
787788
var width: CGFloat = 0
788789

789790
let scalingMode = renderSettings.scalingMode
791+
let useNewScalingRenderer = PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
790792

791793
/// Calculate dimensions in points first
792-
switch scalingMode {
793-
case .stretch:
794-
// Fill the entire parent view — no aspect ratio preserved
795-
width = parentSize.width
796-
height = parentSize.height
797-
798-
case .aspectFill:
799-
// Scale to fill, preserving aspect ratio (may overflow; clipping applied by view hierarchy)
800-
if parentSize.width > parentSize.height {
801-
height = parentSize.height
802-
width = height * ratio
803-
if width < parentSize.width {
804-
width = parentSize.width
805-
height = width / ratio
806-
}
807-
} else {
794+
if useNewScalingRenderer {
795+
switch scalingMode {
796+
case .stretch:
797+
// Fill the entire parent view — no aspect ratio preserved
808798
width = parentSize.width
809-
height = width / ratio
810-
if height < parentSize.height {
799+
height = parentSize.height
800+
801+
case .aspectFill:
802+
// Scale to fill, preserving aspect ratio (may overflow; clipping applied by view hierarchy)
803+
if parentSize.width > parentSize.height {
811804
height = parentSize.height
812805
width = height * ratio
806+
if width < parentSize.width {
807+
width = parentSize.width
808+
height = width / ratio
809+
}
810+
} else {
811+
width = parentSize.width
812+
height = width / ratio
813+
if height < parentSize.height {
814+
height = parentSize.height
815+
width = height * ratio
816+
}
813817
}
814-
}
815818

816-
case .nativeResolution:
817-
// 1:1 pixel mapping — use the core's effective output dimensions directly
818-
width = effectiveSize.width
819-
height = effectiveSize.height
819+
case .nativeResolution:
820+
// 1:1 pixel mapping — use the core's effective output dimensions directly
821+
width = effectiveSize.width
822+
height = effectiveSize.height
820823

821-
case .integerScale:
822-
// Snap to the largest integer multiple that fits
823-
if parentSize.width > parentSize.height {
824-
height = floor(parentSize.height / effectiveSize.height) * effectiveSize.height
825-
width = height * ratio
826-
if width > parentSize.width {
827-
width = parentSize.width
824+
case .integerScale:
825+
// Snap to the largest integer multiple that fits
826+
if parentSize.width > parentSize.height {
827+
height = floor(parentSize.height / effectiveSize.height) * effectiveSize.height
828+
width = height * ratio
829+
if width > parentSize.width {
830+
width = parentSize.width
831+
height = width / ratio
832+
}
833+
} else {
834+
width = floor(parentSize.width / effectiveSize.width) * effectiveSize.width
828835
height = width / ratio
836+
if height > parentSize.height {
837+
height = parentSize.height
838+
width = height * ratio
839+
}
829840
}
830-
} else {
831-
width = floor(parentSize.width / effectiveSize.width) * effectiveSize.width
832-
height = width / ratio
833-
if height > parentSize.height {
841+
842+
case .aspectFit:
843+
// Default: aspect-correct fit (letterbox / pillarbox)
844+
if parentSize.width > parentSize.height {
834845
height = parentSize.height
835846
width = height * ratio
847+
if width > parentSize.width {
848+
width = parentSize.width
849+
height = width / ratio
850+
}
851+
} else {
852+
width = parentSize.width
853+
height = width / ratio
854+
if height > parentSize.height {
855+
height = parentSize.height
856+
width = height * ratio
857+
}
836858
}
837859
}
838-
839-
case .aspectFit:
840-
// Default: aspect-correct fit (letterbox / pillarbox)
860+
} else {
861+
// Legacy layout: honours the old integerScaleEnabled / nativeScaleEnabled booleans.
841862
if parentSize.width > parentSize.height {
842-
height = parentSize.height
863+
if Defaults[.integerScaleEnabled] {
864+
height = floor(parentSize.height / effectiveSize.height) * effectiveSize.height
865+
} else {
866+
height = parentSize.height
867+
}
843868
width = height * ratio
844869
if width > parentSize.width {
845870
width = parentSize.width
846871
height = width / ratio
847872
}
848873
} else {
849-
width = parentSize.width
874+
if Defaults[.integerScaleEnabled] {
875+
width = floor(parentSize.width / effectiveSize.width) * effectiveSize.width
876+
} else {
877+
width = parentSize.width
878+
}
850879
height = width / ratio
851880
if height > parentSize.height {
852881
height = parentSize.height
@@ -877,7 +906,7 @@ class PVMetalViewController : PVGPUViewController, PVRenderDelegate, MTKViewDele
877906
abs(view.frame.height - frame.height) > 0.5
878907

879908
if viewFrameChanged {
880-
if scalingMode.requiresNativeScaleFactor {
909+
if (useNewScalingRenderer ? scalingMode.requiresNativeScaleFactor : Defaults[.nativeScaleEnabled]) {
881910
let scale = UIScreen.main.scale
882911

883912
/// Apply frame to main view without triggering additional layout
@@ -915,7 +944,9 @@ class PVMetalViewController : PVGPUViewController, PVRenderDelegate, MTKViewDele
915944
// Metal render only supports native scale on iOS/tvOS
916945
#if !(os(macOS) || targetEnvironment(macCatalyst))
917946
let screenBounds = UIScreen.main.bounds
918-
let nativeScaleEnabled = Defaults[.scalingMode] == .nativeResolution
947+
let nativeScaleEnabled = PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
948+
? Defaults[.scalingMode] == .nativeResolution
949+
: Defaults[.nativeScaleEnabled]
919950

920951
if lastScreenBounds != screenBounds || lastNativeScaleEnabled != nativeScaleEnabled {
921952
lastScreenBounds = screenBounds
@@ -2057,7 +2088,9 @@ class PVMetalViewController : PVGPUViewController, PVRenderDelegate, MTKViewDele
20572088
#if os(iOS) || os(tvOS)
20582089
let screenBounds = UIScreen.main.bounds
20592090
let screenScale = UIScreen.main.scale
2060-
let useNativeScale = Defaults[.scalingMode] == .nativeResolution
2091+
let useNativeScale = PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
2092+
? Defaults[.scalingMode] == .nativeResolution
2093+
: Defaults[.nativeScaleEnabled]
20612094
#else
20622095
let screenBounds = view.bounds
20632096
let screenScale = view.window?.screen?.backingScaleFactor ?? 1.0
@@ -3355,7 +3388,10 @@ class PVMetalViewController : PVGPUViewController, PVRenderDelegate, MTKViewDele
33553388
metalView.clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
33563389
configureMetalLayer(for: metalView)
33573390

3358-
if Defaults[.scalingMode] == .nativeResolution {
3391+
let _nativeResolutionActive = PVFeatureFlags.shared.isEnabled(.scalingModeRenderer)
3392+
? Defaults[.scalingMode] == .nativeResolution
3393+
: Defaults[.nativeScaleEnabled]
3394+
if _nativeResolutionActive {
33593395
let scale = UIScreen.main.scale
33603396
if scale != 1.0 {
33613397
metalView.layer.contentsScale = scale

0 commit comments

Comments
 (0)