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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ User-visible changes to NX Library are recorded here. Entries are grouped by rel

## Unreleased

### Added

- Game options show **Frame rate** (30 / 60 / 120 FPS), **Button layout** (Switch positions / Xbox labels) and **Screen fill** (Keep aspect ratio / Fill screen) for a game whose app offers them in its `launcher.json`. Other games keep the 60 FPS switch; a choice is written to the game's `env.txt` only once it is made.

### Changed

- Builds run faster: the animated background holds still while a build runs, the shader compile runs two workers above the core count (unless `J` is set), and a build keeps the app out of App Nap at user-initiated priority.

## 0.1.2 — 2026-11-03

### Added
Expand Down
27 changes: 25 additions & 2 deletions Sources/NXLibrary/Views/GameOptions/GameOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ struct GameOptionsView: View {
let onMove: () -> Void
/// Read once: it parses the game's launcher file, which the rows must not do on every draw.
private let supportsRenderScale: Bool
/// Likewise read once: what the app's launcher.json offers beyond the fixed rows.
private let supportsFrameRate: Bool
private let supportsButtonLayout: Bool
private let supportsScreenFill: Bool

@State private var config: GameRuntimeConfig
@State private var level: GameOptionsLevel = .main
Expand Down Expand Up @@ -61,6 +65,10 @@ struct GameOptionsView: View {
self.onShowBuild = onShowBuild
self.onMove = onMove
supportsRenderScale = viewModel.supportsSetting("DM_RENDER_SCALE", for: game)
let listed = LauncherSettingsReader.load(for: game.url)?.settings.map(\.env) ?? []
supportsFrameRate = LauncherSettingsReader.offers("DM_FPS_TARGET", value: "120", for: game.url)
supportsButtonLayout = listed.contains("DM_PAD_LAYOUT")
supportsScreenFill = listed.contains("DM_PRESENT_FILL")
_config = State(initialValue: viewModel.loadRuntimeConfig(for: game))
_cheatsValue = State(initialValue: viewModel.cheatRowValue(for: game))
}
Expand All @@ -85,7 +93,10 @@ struct GameOptionsView: View {
gameName: game.name,
appPath: game.url.path,
canDeleteBundle: game.canDeleteAppBundle(),
cheatsValue: cheatsValue
cheatsValue: cheatsValue,
supportsFrameRate: supportsFrameRate,
supportsButtonLayout: supportsButtonLayout,
supportsScreenFill: supportsScreenFill
)
}

Expand Down Expand Up @@ -343,6 +354,12 @@ struct GameOptionsView: View {
return "\(config.rumbleGain)"
case .renderScale:
return GameOptionsValues.renderScaleLabel(config.renderScale)
case .frameRate:
return "\(config.fpsTarget) FPS"
case .buttonLayout:
return GameOptionsValues.valueLabel(config.buttonLayout, in: GameOptionsValues.buttonLayouts)
case .screenFill:
return GameOptionsValues.valueLabel(config.screenFill, in: GameOptionsValues.screenFills)
default:
return ""
}
Expand Down Expand Up @@ -588,6 +605,12 @@ struct GameOptionsView: View {
config.rumbleGain = GameOptionsValues.steppedRumbleStrength(config.rumbleGain, by: delta)
case .renderScale:
config.renderScale = GameOptionsValues.steppedRenderScale(config.renderScale, by: delta)
case .frameRate:
config.fpsTarget = GameOptionsValues.steppedFrameRate(config.fpsTarget, by: delta)
case .buttonLayout:
config.buttonLayout = GameOptionsValues.steppedValue(config.buttonLayout, in: GameOptionsValues.buttonLayouts, by: delta)
case .screenFill:
config.screenFill = GameOptionsValues.steppedValue(config.screenFill, in: GameOptionsValues.screenFills, by: delta)
default:
break
}
Expand Down Expand Up @@ -637,7 +660,7 @@ struct GameOptionsView: View {
case .metalHUD:
SoundEffectService.shared.play(.click)
config.enableMetalHUD.toggle()
case .consoleMode, .language, .rumbleStrength, .renderScale:
case .consoleMode, .language, .rumbleStrength, .renderScale, .frameRate, .buttonLayout, .screenFill:
adjust(row, by: 1)
case .rumble:
SoundEffectService.shared.play(.click)
Expand Down
4 changes: 3 additions & 1 deletion Sources/NXLibrary/Views/Shell/AppShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public struct AppShellView: View {
private var backdropStyle: SeleneBackdropStyle {
SeleneBackdropStyle(wallpaperURL: theme.wallpaper(for: settings.settings.wallpaperID)?.url,
sizing: settings.settings.backgroundSizing,
animated: settings.settings.animatedBackground,
// Held still while a build runs: the moving backdrop cost close to a core (NXLibrary plus WindowServer)
// that the shader compile, which uses every core, could have had.
animated: settings.settings.animatedBackground && !viewModel.isBuilding,
brightness: settings.settings.wallpaperBrightness)
}

Expand Down
54 changes: 48 additions & 6 deletions Sources/NXLibraryCore/Models/GameOptionsPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public enum GameOptionsLevel: Equatable, Sendable {
/// Every focusable row of Game options. Headings and notes are not rows (see `GameOptionsEntry`).
public enum GameOptionsRow: String, CaseIterable, Sendable {
case play, move, favorite, hidden
case fullscreen, fps60, metalFX, metalHUD, consoleMode, language, rumble, rumbleStrength
case fullscreen, fps60, frameRate, metalFX, metalHUD, consoleMode, language, rumble, rumbleStrength
case buttonLayout, screenFill
case renderScale, restoreDefaults
case cheats, rebuild, showInFinder, openSaves, editDetails, removeGame
case changeArtwork, changeMetadata, storePage, attachSources, openLog, removeSources
Expand All @@ -29,6 +30,9 @@ public enum GameOptionsRow: String, CaseIterable, Sendable {
case .hidden: return "Hidden"
case .fullscreen: return "Fullscreen"
case .fps60: return "60 FPS"
case .frameRate: return "Frame rate"
case .buttonLayout: return "Button layout"
case .screenFill: return "Screen fill"
case .metalFX: return "MetalFX"
case .metalHUD: return "Metal HUD"
case .consoleMode: return "Console mode"
Expand Down Expand Up @@ -59,7 +63,9 @@ public enum GameOptionsRow: String, CaseIterable, Sendable {
case .favorite: return .star
case .hidden: return .hidden
case .fullscreen: return .monitor
case .fps60: return .sync
case .fps60, .frameRate: return .sync
case .buttonLayout: return .gamepad
case .screenFill: return .monitor
case .metalFX: return .tune
case .metalHUD: return .timeline
case .consoleMode: return .gamepad
Expand Down Expand Up @@ -92,7 +98,7 @@ public enum GameOptionsRow: String, CaseIterable, Sendable {
/// A value chip on the trailing edge: Left/Right cycle it, A cycles forward.
public var isChoice: Bool {
switch self {
case .consoleMode, .language, .rumbleStrength, .renderScale: return true
case .consoleMode, .language, .rumbleStrength, .renderScale, .frameRate, .buttonLayout, .screenFill: return true
default: return false
}
}
Expand Down Expand Up @@ -122,6 +128,11 @@ public struct GameRemovalCopy: Equatable, Sendable {

public struct GameOptionsPresentation: Equatable {
public let showRenderScale: Bool
/// The app offers 120 FPS: Frame rate (30 / 60 / 120) replaces the 60 FPS switch.
public let showFrameRate: Bool
/// The app offers DM_PAD_LAYOUT / DM_PRESENT_FILL (its launcher.json lists them).
public let showButtonLayout: Bool
public let showScreenFill: Bool
public let canPlay: Bool
public let sourcesDetached: Bool
/// The Metal gate refuses new builds (a known report that does not pass, without the override).
Expand All @@ -139,7 +150,11 @@ public struct GameOptionsPresentation: Equatable {
public init(supportsRenderScale: Bool, isPlayable: Bool, sourcesDetached: Bool,
toolchainBlocked: Bool = false, toolchainHint: String? = nil, hasStorePage: Bool = false,
gameName: String = "", appPath: String = "", canDeleteBundle: Bool = false,
cheatsValue: String = "None") {
cheatsValue: String = "None", supportsFrameRate: Bool = false,
supportsButtonLayout: Bool = false, supportsScreenFill: Bool = false) {
self.showFrameRate = supportsFrameRate
self.showButtonLayout = supportsButtonLayout
self.showScreenFill = supportsScreenFill
self.cheatsValue = cheatsValue
self.showRenderScale = supportsRenderScale
self.canPlay = isPlayable
Expand Down Expand Up @@ -203,7 +218,9 @@ public struct GameOptionsPresentation: Equatable {
case .favorite: return "On your Favorites tab."
case .hidden: return "Hidden games stay out of your lists."
case .fullscreen: return "Launch mode"
case .fps60: return "Frame-rate target"
case .fps60, .frameRate: return "Frame-rate target"
case .buttonLayout: return "Xbox and PlayStation face buttons"
case .screenFill: return "Black bars or the whole screen"
case .metalFX: return "Spatial upscaling"
case .metalHUD: return "Performance overlay"
case .consoleMode: return "Console profile"
Expand Down Expand Up @@ -231,6 +248,12 @@ public struct GameOptionsPresentation: Equatable {
switch row {
case .fps60:
return "On targets 60 FPS; Off targets 30 FPS. Some games run too fast at 60 FPS."
case .frameRate:
return "30, 60 or 120 FPS (DM_FPS_TARGET). 120 FPS needs a 120 Hz display and uses more power; the game offers it only where its timing was checked at 120."
case .buttonLayout:
return "Xbox labels: an Xbox or PlayStation controller's A/Cross presses the game's A, B/Circle its B, X/Square its X, Y/Triangle its Y (DM_PAD_LAYOUT=xbox). Switch positions keeps the Switch's places."
case .screenFill:
return "Fill screen stretches the picture to the display's shape with no black bars (DM_PRESENT_FILL=stretch); Keep aspect ratio shows the game's 16:9 with bars."
case .metalFX:
return "Applies Apple MetalFX spatial upscaling (DM_PRESENT_UPSCALE=metalfx). It sharpens the 3D viewport to match your Mac display without increasing internal render workload."
case .metalHUD:
Expand All @@ -255,8 +278,10 @@ public struct GameOptionsPresentation: Equatable {
var entries: [GameOptionsEntry] = [.row(.play), .row(.editDetails), .row(.cheats), .row(.openSaves)]
if origin == .spotlight { entries.append(.row(.move)) }
entries += [.row(.favorite), .row(.hidden), .heading("Next launch")]
entries += [.fullscreen, .fps60, .metalFX, .metalHUD, .consoleMode, .language, .rumble, .rumbleStrength]
entries += [.fullscreen, showFrameRate ? .frameRate : .fps60, .metalFX, .metalHUD, .consoleMode, .language, .rumble, .rumbleStrength]
.map(GameOptionsEntry.row)
if showButtonLayout { entries.append(.row(.buttonLayout)) }
if showScreenFill { entries.append(.row(.screenFill)) }
if showRenderScale {
entries.append(.row(.renderScale))
} else {
Expand Down Expand Up @@ -320,6 +345,23 @@ public enum GameOptionsValues {
public static let rumbleStrengthCount = 4
public static let renderScaleStops = Array(stride(from: 50, through: 100, by: 5))

public static let frameRates = [30, 60, 120]
public static let buttonLayouts = [("position", "Switch positions"), ("xbox", "Xbox labels")]
public static let screenFills = [("fit", "Keep aspect ratio"), ("stretch", "Fill screen")]

public static func steppedFrameRate(_ fps: Int, by delta: Int) -> Int {
frameRates[steppedIndex(frameRates.firstIndex(of: fps), by: delta, count: frameRates.count)]
}

/// The next value of a two-or-more value list; nil (the app's own choice) starts at the first.
public static func steppedValue(_ value: String?, in list: [(String, String)], by delta: Int) -> String {
list[steppedIndex(list.firstIndex { $0.0 == value }, by: delta, count: list.count)].0
}

public static func valueLabel(_ value: String?, in list: [(String, String)]) -> String {
list.first { $0.0 == value }?.1 ?? "App default"
}

public static func consoleModeLabel(docked: Bool) -> String {
docked ? "Docked" : "Handheld"
}
Expand Down
30 changes: 24 additions & 6 deletions Sources/NXLibraryCore/Models/GameRuntimeConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ public struct GameRuntimeConfig: Codable, Equatable {
public var rumble: Bool
public var rumbleGain: Int
public var fullscreen: Bool
/// With `enable60FPS`: 120 FPS instead of 60, for a game whose app offers it (its launcher.json lists DM_FPS_TARGET 120).
public var highFrameRate: Bool
/// DM_PAD_LAYOUT ("position" or "xbox"); nil leaves the app's own choice.
public var buttonLayout: String?
/// DM_PRESENT_FILL ("fit" or "stretch"); nil leaves the app's own choice.
public var screenFill: String?

public init(enable60FPS: Bool = true, enableMetalHUD: Bool = false,
isDockedMode: Bool = true, renderScale: Double = 1.0,
enableMetalFXUpscale: Bool = false, language: String = "auto",
rumble: Bool = true, rumbleGain: Int = 1, fullscreen: Bool = false) {
rumble: Bool = true, rumbleGain: Int = 1, fullscreen: Bool = false,
highFrameRate: Bool = false, buttonLayout: String? = nil, screenFill: String? = nil) {
self.enable60FPS = enable60FPS
self.enableMetalHUD = enableMetalHUD
self.isDockedMode = isDockedMode
Expand All @@ -24,11 +31,15 @@ public struct GameRuntimeConfig: Codable, Equatable {
self.rumble = rumble
self.rumbleGain = rumbleGain
self.fullscreen = fullscreen
self.highFrameRate = highFrameRate
self.buttonLayout = buttonLayout
self.screenFill = screenFill
}

private enum CodingKeys: String, CodingKey {
case enable60FPS, enableMetalHUD, isDockedMode, renderScale, enableMetalFXUpscale
case language, rumble, rumbleGain, fullscreen
case highFrameRate, buttonLayout, screenFill
}

public init(from decoder: Decoder) throws {
Expand All @@ -42,14 +53,17 @@ public struct GameRuntimeConfig: Codable, Equatable {
language: try values.decodeIfPresent(String.self, forKey: .language) ?? "auto",
rumble: try values.decodeIfPresent(Bool.self, forKey: .rumble) ?? true,
rumbleGain: try values.decodeIfPresent(Int.self, forKey: .rumbleGain) ?? 1,
fullscreen: try values.decodeIfPresent(Bool.self, forKey: .fullscreen) ?? false
fullscreen: try values.decodeIfPresent(Bool.self, forKey: .fullscreen) ?? false,
highFrameRate: try values.decodeIfPresent(Bool.self, forKey: .highFrameRate) ?? false,
buttonLayout: try values.decodeIfPresent(String.self, forKey: .buttonLayout),
screenFill: try values.decodeIfPresent(String.self, forKey: .screenFill)
)
}

// Core-facing aliases; the launcher views keep their original property names.
public var fpsTarget: Int {
get { enable60FPS ? 60 : 30 }
set { enable60FPS = newValue != 30 }
get { enable60FPS ? (highFrameRate ? 120 : 60) : 30 }
set { enable60FPS = newValue != 30; highFrameRate = newValue == 120 }
}
public var metalHUD: Bool {
get { enableMetalHUD }
Expand All @@ -66,7 +80,7 @@ public struct GameRuntimeConfig: Codable, Equatable {

public func toEnvironmentDict() -> [String: String] {
var values: [String: String] = [
"DM_FPS_TARGET": enable60FPS ? "60" : "30",
"DM_FPS_TARGET": "\(fpsTarget)",
"DM_METAL_HUD": enableMetalHUD ? "1" : "0",
"DM_OPMODE": isDockedMode ? "1" : "0",
"DM_RENDER_SCALE": String(format: "%.2f", renderScale),
Expand All @@ -75,6 +89,8 @@ public struct GameRuntimeConfig: Codable, Equatable {
"DM_RUMBLE_GAIN": "\(rumbleGain)",
"DM_FULLSCREEN": fullscreen ? "1" : "0",
]
if let buttonLayout, !buttonLayout.isEmpty { values["DM_PAD_LAYOUT"] = buttonLayout }
if let screenFill, !screenFill.isEmpty { values["DM_PRESENT_FILL"] = screenFill }
// "auto" lets the engine's own language (DMLanguage) apply.
if language != "auto", !language.isEmpty {
values["DM_LANG"] = language
Expand Down Expand Up @@ -178,7 +194,9 @@ public struct GameRuntimeConfig: Codable, Equatable {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let value = parts[1].trimmingCharacters(in: .whitespaces)
switch key {
case "DM_FPS_TARGET": config.enable60FPS = value != "30"
case "DM_FPS_TARGET": config.fpsTarget = Int(value) ?? 60
case "DM_PAD_LAYOUT": config.buttonLayout = value
case "DM_PRESENT_FILL": config.screenFill = value
case "DM_METAL_HUD": config.enableMetalHUD = value == "1"
case "DM_OPMODE": config.isDockedMode = value != "0"
case "DM_RENDER_SCALE": config.renderScale = Double(value) ?? 1.0
Expand Down
5 changes: 5 additions & 0 deletions Sources/NXLibraryCore/Models/LauncherSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ public enum LauncherSettingsReader {
return true
}

/// Whether the app's launcher.json lists this value for the setting (DM_FPS_TARGET 120). Apps without the file offer only the fixed UI.
public static func offers(_ env: String, value: String, for appURL: URL) -> Bool {
load(for: appURL)?.settings.first { $0.env == env }?.options?.contains { $0.value == value } ?? false
}

public static func hasRenderScaleFixup(_ appURL: URL) -> Bool {
let url = appURL
.appendingPathComponent("Contents/Resources/dm/main.fixups.tsv")
Expand Down
7 changes: 7 additions & 0 deletions Sources/NXLibraryCore/Services/BuildCommandBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ public enum BuildCommandBuilder {

var env = toolchain.environment(base: ProcessInfo.processInfo.environment, developerDir: developerDir)
env["PYTHONUNBUFFERED"] = "1"
// The engine runs its shader workers at J (default: the core count). A Metal compile also waits on its
// process start and file writes, so two more than the cores keeps them all busy: on an 8-core M1 Pro,
// a 65,047-shader game went from 3.7 to 4.4 shaders a second (with the backdrop still). A J the user set wins.
if env["J"] == nil { env["J"] = String(Self.shaderJobs(cores: ProcessInfo.processInfo.activeProcessorCount)) }
return BuildCommand(executable: toolchain.python, arguments: args,
environment: env, workingDirectory: repo)
}

/// Shader workers for a Mac with this many cores.
public static func shaderJobs(cores: Int) -> Int { max(2, cores) + 2 }
}
10 changes: 10 additions & 0 deletions Sources/NXLibraryCore/Services/BuildService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public final class BuildService {
private var eventHandler: ((BuildEvent) -> Void)?
private var completion: ((BuildResult) -> Void)?
private var sleepAssertion: IOPMAssertionID?
/// Keeps App Nap off the app while it builds: hidden or in the background, a napping app's timers and
/// pipes slow down, and the build's progress and output handling with them.
private var buildActivity: NSObjectProtocol?

public init(fileManager: FileManager = .default) { self.fm = fileManager }

Expand Down Expand Up @@ -94,6 +97,8 @@ public final class BuildService {
) == kIOReturnSuccess {
sleepAssertion = assertion
}
buildActivity = ProcessInfo.processInfo.beginActivity(
options: [.userInitiated, .idleSystemSleepDisabled], reason: "NX Library build")

let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
Expand All @@ -103,6 +108,7 @@ public final class BuildService {
command.executable.path] + command.arguments
process.currentDirectoryURL = command.workingDirectory
process.environment = command.environment
process.qualityOfService = .userInitiated
self.process = process

let out = Pipe()
Expand Down Expand Up @@ -275,5 +281,9 @@ public final class BuildService {
IOPMAssertionRelease(sleepAssertion)
self.sleepAssertion = nil
}
if let buildActivity {
ProcessInfo.processInfo.endActivity(buildActivity)
self.buildActivity = nil
}
}
}
Loading