Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/3619.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added
- **AspectRatioOverride enum** — New `AspectRatioOverride` enum in `PVPrimitives` defines per-core aspect ratio overrides (Auto, 4:3, 16:9, 1:1, 8:7, Stretch) with display metadata and aspect ratio values.
- **CoreOptional aspect ratio protocol** — `CoreOptional` now declares `supportedAspectRatioOverrides` and `preferredAspectRatioOverride` static properties; default implementations return `.auto` so all existing cores are unaffected.

### Changed
- **ScalingMode renderer enabled by default** — The `scalingModeRenderer` feature flag is now enabled by default; the new `ScalingMode`-driven renderer paths (Stretch, Aspect Fill, Integer Scale, Native Resolution) are active for all users. Legacy boolean shims remain for backwards compatibility.
10 changes: 10 additions & 0 deletions .github/prompts/reviewer-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,16 @@ will fail Linux CI — flag as 🟡 MINOR if in a Tier 0–2 module, ⚪ NIT oth
- **Flag 🟠 MAJOR** if new DSU code modifies `listener`/`browser` state outside the `self.queue` serial queue in the Discovery classes.
- **Flag 🟠 MAJOR** if `DSUSocket.startListening()` is inlined into `init` — the two-step design is load-bearing.

### Screen Scaling — ScalingMode + AspectRatioOverride (Phase 1 #3616 / Phase 2 #3617)
- `ScalingMode` enum is in `PVSettings/Sources/PVSettings/Settings/Model/ScalingMode.swift` — replaces the legacy `nativeScaleEnabled` / `integerScaleEnabled` boolean pair. The old booleans are `@available(*, deprecated)` shims.
- `AspectRatioOverride` enum is in `PVPrimitives/Sources/PVPrimitives/System/AspectRatioOverride.swift` — per-core display geometry override. Default `.auto` means no override.
- `CoreOptional.supportedAspectRatioOverrides` — static var declaring which `AspectRatioOverride` cases the core supports. Default: `[.auto]`.
- `CoreOptional.preferredAspectRatioOverride` — static var returning the currently selected override. Default: `.auto`.
- The `scalingModeRenderer` feature flag in `PVFeatureFlags` is **enabled by default** as of Phase 2. The renderer uses `ScalingMode` for layout; **do not** re-introduce the old boolean code paths.
- **Flag 🔴 CRITICAL** if new code compares against `nativeScaleEnabled`/`integerScaleEnabled` directly instead of reading `scalingMode`.
- **Flag 🟠 MAJOR** if a new core overrides `preferredAspectRatioOverride` without also declaring the case in `supportedAspectRatioOverrides`.
- **Flag 🟡 MINOR** if a core's `supportedAspectRatioOverrides` includes `.ratio_16_9` but no widescreen option key is wired in the core options.

## GitHub Workflow Awareness

Reviewers should be aware of — but NOT flag as code issues — the following:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
//

import Foundation
import PVPrimitives


Check warning on line 12 in PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Limit vertical whitespace to a single empty line; currently 2 (vertical_whitespace)
public protocol CoreOptional {//where Self: EmulatorCoreIOInterface {

Check warning on line 13 in PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer at least one space after slashes for comments (comment_spacing)
/// The options available for this core
static var options: [CoreOption] { get }

Expand All @@ -20,6 +21,24 @@
/// Set this when a game is loaded and clear it on unload.
static var currentGameMD5: String? { get }

/// The set of aspect ratio overrides this core supports.
///
/// Cores that support widescreen hacks, aspect ratio selection, or other
/// display geometry modifications should return the relevant cases here.
/// The UI uses this list to show only the options the core actually supports.
///
/// The default implementation returns `[.auto]` (no overrides available).
static var supportedAspectRatioOverrides: [AspectRatioOverride] { get }

/// The aspect ratio override currently selected for this core.
///
/// The renderer reads this property when compositing the frame. Returning
/// `.auto` (the default) means the core's native reported aspect ratio is used.
///
/// Cores that store this preference in their own `CoreOption` key should
/// override this property to read from `UserDefaults` via `string(forOption:)`.
static var preferredAspectRatioOverride: AspectRatioOverride { get }

// static func bool(forOption option: String) -> Bool
// static func int(forOption option: String) -> Int
// static func float(forOption option: String) -> Float
Expand All @@ -40,6 +59,12 @@
/// Default implementation: no per-game MD5 override.
static var currentGameMD5: String? { nil }

/// Default: only `.auto` is available (no override supported).
static var supportedAspectRatioOverrides: [AspectRatioOverride] { [.auto] }

/// Default: use the core's natural aspect ratio.
static var preferredAspectRatioOverride: AspectRatioOverride { .auto }

/// Reset a specific set of options to their default values
/// - Parameter options: The options to reset
static func resetOptions(_ options: [CoreOption]) {
Expand Down
113 changes: 113 additions & 0 deletions PVPrimitives/Sources/PVPrimitives/System/AspectRatioOverride.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// AspectRatioOverride.swift
// PVPrimitives
//
// Created by Provenance Emu on 2026-04-03.
// Copyright © 2026 Provenance Emu. All rights reserved.
//

import Foundation

/// A per-core aspect ratio override that can be applied on top of the global `ScalingMode`.
///
/// Each emulator core may support a subset of these overrides depending on the capabilities
/// of the underlying emulator. Cores declare which overrides they support via the
/// `CoreOptional.supportedAspectRatioOverrides` property.
///
/// When a core returns a non-`.auto` value from `CoreOptional.preferredAspectRatioOverride`,
/// the renderer applies that override instead of (or in combination with) the global
/// `ScalingMode` setting.
///
/// ## Override vs ScalingMode
/// | `ScalingMode` | `AspectRatioOverride` | Result |
/// |---|---|---|
/// | `.aspectFit` | `.ratio_16_9` | Widescreen content letterboxed to fit |
/// | `.aspectFill` | `.ratio_4_3` | 4:3 content zoomed to fill screen |
/// | `.stretch` | `.auto` | Core's native ratio stretched to fill |
/// | Any | `.ratio_1_1` | Square-pixel / pixel-perfect mapping |
public enum AspectRatioOverride: String, Codable, Equatable, Hashable,
CaseIterable, Sendable, CustomStringConvertible {

/// Let the core report its natural aspect ratio — no override applied.
/// This is the default for all cores.
case auto = "auto"

/// Force a 4:3 display ratio (classic TV / arcade).
case ratio_4_3 = "4:3"

/// Force a 16:9 widescreen display ratio.
case ratio_16_9 = "16:9"

/// Force a 1:1 square-pixel ratio (equal horizontal and vertical scaling).
case ratio_1_1 = "1:1"

/// Force a 8:7 ratio used by the SNES / Super Famicom (non-square pixels).
case ratio_8_7 = "8:7"

/// Stretch to fill the display, ignoring all aspect ratio constraints.
/// Equivalent to `ScalingMode.stretch` but applied at the per-core level.
case stretch = "stretch"

// MARK: - Display Metadata

public var displayName: String {
switch self {
case .auto: return "Auto"
case .ratio_4_3: return "4:3"
case .ratio_16_9: return "16:9"
case .ratio_1_1: return "1:1"
case .ratio_8_7: return "8:7"
case .stretch: return "Stretch"
}
}

public var subtitle: String {
switch self {
case .auto:
return "Use the aspect ratio reported by the core"
case .ratio_4_3:
return "Classic TV / arcade (4 wide, 3 tall)"
case .ratio_16_9:
return "Widescreen — enables widescreen hack if supported"
case .ratio_1_1:
return "Square pixels — 1:1 horizontal/vertical mapping"
case .ratio_8_7:
return "SNES native — non-square pixel correction"
case .stretch:
return "Stretch to fill — ignores aspect ratio"
}
}

public var symbolName: String {
switch self {
case .auto: return "aspectratio"
case .ratio_4_3: return "tv"
case .ratio_16_9: return "tv.fill"
case .ratio_1_1: return "square"
case .ratio_8_7: return "rectangle"
case .stretch: return "arrow.left.and.right.righttriangle.left.righttriangle.right"
}
}

/// The floating-point aspect ratio value, or `nil` for `.auto` and `.stretch`.
public var aspectRatioValue: Float? {
switch self {
case .auto: return nil
case .ratio_4_3: return 4.0 / 3.0
case .ratio_16_9: return 16.0 / 9.0
case .ratio_1_1: return 1.0
case .ratio_8_7: return 8.0 / 7.0
case .stretch: return nil
}
}

/// Whether this override requests widescreen rendering (16:9 or wider).
public var isWidescreen: Bool {
switch self {
case .ratio_16_9: return true
default: return false
}
}

public var description: String { displayName }
}
116 changes: 116 additions & 0 deletions PVPrimitives/Tests/PVPrimitivesTests/AspectRatioOverrideTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// AspectRatioOverrideTests.swift
// PVPrimitivesTests
//
// Created by Provenance Emu on 2026-04-03.
// Copyright © 2026 Provenance Emu. All rights reserved.
//

import XCTest
@testable import PVPrimitives

final class AspectRatioOverrideTests: XCTestCase {

// MARK: - Raw values

func testAutoRawValue() {
XCTAssertEqual(AspectRatioOverride.auto.rawValue, "auto")
}

func testRatio4_3RawValue() {
XCTAssertEqual(AspectRatioOverride.ratio_4_3.rawValue, "4:3")
}

func testRatio16_9RawValue() {
XCTAssertEqual(AspectRatioOverride.ratio_16_9.rawValue, "16:9")
}

func testRatio1_1RawValue() {
XCTAssertEqual(AspectRatioOverride.ratio_1_1.rawValue, "1:1")
}

func testRatio8_7RawValue() {
XCTAssertEqual(AspectRatioOverride.ratio_8_7.rawValue, "8:7")
}

func testStretchRawValue() {
XCTAssertEqual(AspectRatioOverride.stretch.rawValue, "stretch")
}

// MARK: - CaseIterable

func testAllCasesCount() {
XCTAssertEqual(AspectRatioOverride.allCases.count, 6)
}

func testAllCasesContainsAuto() {
XCTAssertTrue(AspectRatioOverride.allCases.contains(.auto))
}

// MARK: - aspectRatioValue

func testAutoAspectRatioValueIsNil() {
XCTAssertNil(AspectRatioOverride.auto.aspectRatioValue)
}

func testStretchAspectRatioValueIsNil() {
XCTAssertNil(AspectRatioOverride.stretch.aspectRatioValue)
}

func testRatio4_3Value() {
XCTAssertEqual(AspectRatioOverride.ratio_4_3.aspectRatioValue, 4.0 / 3.0, accuracy: 0.0001)
}

func testRatio16_9Value() {
XCTAssertEqual(AspectRatioOverride.ratio_16_9.aspectRatioValue, 16.0 / 9.0, accuracy: 0.0001)
}

func testRatio1_1Value() {
XCTAssertEqual(AspectRatioOverride.ratio_1_1.aspectRatioValue, 1.0, accuracy: 0.0001)
}

func testRatio8_7Value() {
XCTAssertEqual(AspectRatioOverride.ratio_8_7.aspectRatioValue, 8.0 / 7.0, accuracy: 0.0001)
}

// MARK: - isWidescreen

func testOnly16_9IsWidescreen() {
for override in AspectRatioOverride.allCases {
if override == .ratio_16_9 {
XCTAssertTrue(override.isWidescreen, "\(override) should be widescreen")
} else {
XCTAssertFalse(override.isWidescreen, "\(override) should not be widescreen")
}
}
}

// MARK: - Codable round-trip

func testCodableRoundTrip() throws {
let encoder = JSONEncoder()
let decoder = JSONDecoder()
for override in AspectRatioOverride.allCases {
let data = try encoder.encode(override)
let decoded = try decoder.decode(AspectRatioOverride.self, from: data)
XCTAssertEqual(decoded, override, "Codable round-trip failed for \(override)")
}
}

// MARK: - displayName

func testAutoDisplayName() {
XCTAssertEqual(AspectRatioOverride.auto.displayName, "Auto")
}

func testStretchDisplayName() {
XCTAssertEqual(AspectRatioOverride.stretch.displayName, "Stretch")
}

// MARK: - description

func testDescriptionMatchesDisplayName() {
for override in AspectRatioOverride.allCases {
XCTAssertEqual(override.description, override.displayName)
}
}
}
Loading