diff --git a/.changelog/3619.md b/.changelog/3619.md new file mode 100644 index 0000000000..15a11193cd --- /dev/null +++ b/.changelog/3619.md @@ -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. diff --git a/.github/prompts/reviewer-context.md b/.github/prompts/reviewer-context.md index e68b08c379..cb9391c042 100644 --- a/.github/prompts/reviewer-context.md +++ b/.github/prompts/reviewer-context.md @@ -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: diff --git a/PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift b/PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift index 5384fea3d6..105ee4cf09 100644 --- a/PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift +++ b/PVCoreBridge/Sources/PVCoreBridge/CoreOptions/Protocols/CoreOptional.swift @@ -7,6 +7,7 @@ // import Foundation +import PVPrimitives public protocol CoreOptional {//where Self: EmulatorCoreIOInterface { @@ -20,6 +21,24 @@ public protocol CoreOptional {//where Self: EmulatorCoreIOInterface { /// 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 @@ -40,6 +59,12 @@ public extension CoreOptional { /// 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]) { diff --git a/PVPrimitives/Sources/PVPrimitives/System/AspectRatioOverride.swift b/PVPrimitives/Sources/PVPrimitives/System/AspectRatioOverride.swift new file mode 100644 index 0000000000..c3362daef9 --- /dev/null +++ b/PVPrimitives/Sources/PVPrimitives/System/AspectRatioOverride.swift @@ -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 } +} diff --git a/PVPrimitives/Tests/PVPrimitivesTests/AspectRatioOverrideTests.swift b/PVPrimitives/Tests/PVPrimitivesTests/AspectRatioOverrideTests.swift new file mode 100644 index 0000000000..e71eb13d61 --- /dev/null +++ b/PVPrimitives/Tests/PVPrimitivesTests/AspectRatioOverrideTests.swift @@ -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) + } + } +}