]*>\s*| ]*>\s*]*href=['"](?:\./)?detail\.php\?id=(\d{1,9})['"][^>]*>.*?\s* | \s*]*>\s*([^<]+)"#,
+ options: [.caseInsensitive, .dotMatchesLineSeparators]) else { return [] }
+ return regex.matches(in: html, range: NSRange(html.startIndex..., in: html)).prefix(100).compactMap { m in
+ guard let r = Range(m.range(at: 1), in: html), let t = Range(m.range(at: 2), in: html),
+ let id = Int(html[r]), id > 0 else { return nil }
+ return Result(id: id, title: LaunchBoxArt.unescape(String(html[t])))
+ }
+ }
+
+ /// Apply rules for all agents / NXLibrary, with the longest matching path winning and Allow winning ties.
+ /// An unavailable or unrecognizable robots response does not authorize automated image retrieval.
+ static func allowsIcons(in robots: String, path: String) -> Bool {
+ typealias Rule = (field: String, path: String)
+ var groups: [(agents: [String], rules: [Rule])] = []
+ var agents: [String] = [], rules: [Rule] = []
+ for raw in robots.components(separatedBy: .newlines) {
+ let line = raw.components(separatedBy: "#")[0]
+ let pair = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ guard pair.count == 2 else { continue }
+ let field = pair[0].lowercased(), value = pair[1]
+ if field == "user-agent" {
+ if !rules.isEmpty { groups.append((agents, rules)); agents = []; rules = [] }
+ agents.append(value.lowercased())
+ } else if field == "allow" || field == "disallow" {
+ rules.append((field, value))
+ }
+ }
+ if !agents.isEmpty { groups.append((agents, rules)) }
+ guard !groups.isEmpty else { return false }
+ func specificity(_ names: [String]) -> Int {
+ names.map { $0 == "*" ? 0 : (!$0.isEmpty && "nxlibrary".hasPrefix($0) ? $0.count : -1) }.max() ?? -1
+ }
+ let agentMatch = groups.map { specificity($0.agents) }.max() ?? -1
+ var best = -1, allowed = true
+ for group in groups where agentMatch >= 0 && specificity(group.agents) == agentMatch {
+ for rule in group.rules where !rule.path.isEmpty {
+ let anchored = rule.path.hasSuffix("$")
+ let text = anchored ? String(rule.path.dropLast()) : rule.path
+ let pattern = "^" + text.components(separatedBy: "*")
+ .map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: ".*") + (anchored ? "$" : "")
+ guard path.range(of: pattern, options: .regularExpression) != nil else { continue }
+ let length = text.filter { $0 != "*" }.count
+ if length > best || length == best && rule.field == "allow" {
+ best = length; allowed = rule.field == "allow"
+ }
+ }
+ }
+ return allowed
+ }
+
+ static func match(name: String, titleID: String, results: [Result]) -> Result? {
+ let query = normalized(name)
+ guard !query.isEmpty else { return nil }
+ let edition = " nintendo switch 2 edition"
+ if titleID.hasPrefix("04"), !query.hasSuffix(edition),
+ results.contains(where: { normalized($0.title) == query + edition }) { return nil }
+ let exact = results.filter { normalized($0.title) == query }
+ guard Set(exact.map(\.id)).count == 1 else { return nil }
+ return exact.first
+ }
+}
+
+/// Title DB first, then one unambiguous Switch Icon Showdown result. Images stay in the artwork cache,
+/// never in ArtworkPicks. Misses are cached for 15 minutes; successful images work offline.
+/// Concurrent requests for the same game share a lookup; network failures cannot stop a build.
+actor AutomaticIconResolver {
+ private let directory: URL
+ private let client: ArtworkHTTPClient
+ private var pending: [String: Task] = [:]
+
+ init(directory: URL, client: ArtworkHTTPClient) { self.directory = directory; self.client = client }
+
+ func resolve(titleID: String, name: String) async -> URL? {
+ guard titleID.count == 16, titleID.allSatisfy(\.isHexDigit), !SwitchIconShowdown.normalized(name).isEmpty else { return nil }
+ let title = titleID.uppercased()
+ let folder = directory.appendingPathComponent(title)
+ let image = folder.appendingPathComponent("square.png")
+ if let data = try? Data(contentsOf: image), CGImageSourceCreateWithData(data as CFData, nil) != nil { return image }
+ let key = title + ":" + SwitchIconShowdown.normalized(name)
+ if let work = pending[key] { return await work.value }
+ let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
+ let miss = folder.appendingPathComponent("square-miss.txt")
+ if (try? String(contentsOf: miss, encoding: .utf8)) == hash,
+ let attrs = try? FileManager.default.attributesOfItem(atPath: miss.path),
+ let date = attrs[.modificationDate] as? Date, Date().timeIntervalSince(date) < 900 { return nil }
+ let work = Task { [client] in
+ await Self.lookup(titleID: title, name: name, folder: folder, image: image, miss: miss, missKey: hash, client: client)
+ }
+ pending[key] = work
+ let result = await work.value
+ pending[key] = nil
+ return result
+ }
+
+ private static func lookup(titleID: String, name: String, folder: URL, image: URL, miss: URL,
+ missKey: String, client: ArtworkHTTPClient) async -> URL? {
+ let primary = URL(string: "https://api.nlib.cc/nx/\(titleID)/icon")!
+ if let data = await get(primary, client: client), let png = validImage(data),
+ store(png, image: image, source: "Title DB", folder: folder) { return image }
+ var query = URLComponents(string: "https://www.switchiconshowdown.com/search.php")!
+ query.queryItems = [URLQueryItem(name: "query", value: name)]
+ let robotsURL = URL(string: "https://www.switchiconshowdown.com/robots.txt")!
+ if let rulesData = await get(robotsURL, client: client, limit: 256 * 1024),
+ let rules = String(data: rulesData, encoding: .utf8),
+ let search = query.url, SwitchIconShowdown.allowsIcons(in: rules, path: search.path),
+ let data = await get(search, client: client, limit: 2 * 1024 * 1024),
+ let html = String(data: data, encoding: .utf8),
+ let chosen = SwitchIconShowdown.match(name: name, titleID: titleID, results: SwitchIconShowdown.results(in: html)),
+ let url = URL(string: "https://www.switchiconshowdown.com/images/\(chosen.id).png"),
+ SwitchIconShowdown.allowsIcons(in: rules, path: url.path),
+ let bytes = await get(url, client: client), let png = validImage(bytes),
+ store(png, image: image, source: "Switch Icon Showdown", folder: folder) { return image }
+ try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ try? Data(missKey.utf8).write(to: miss, options: .atomic)
+ return nil
+ }
+
+ private static func get(_ url: URL, client: ArtworkHTTPClient, limit: Int = 8 * 1024 * 1024) async -> Data? {
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 8
+ request.setValue("NXLibrary (macOS; automatic icon lookup)", forHTTPHeaderField: "User-Agent")
+ guard let (data, response) = try? await client.data(for: request), response.statusCode == 200,
+ !data.isEmpty, data.count <= limit, let final = response.url,
+ final.scheme == "https", final.user == nil, final.password == nil,
+ final.port == nil || final.port == 443,
+ ["api.nlib.cc", "www.switchiconshowdown.com", "switchiconshowdown.com"].contains(final.host?.lowercased() ?? "") else { return nil }
+ return data
+ }
+
+ private static func validImage(_ data: Data) -> Data? {
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
+ let width = properties[kCGImagePropertyPixelWidth] as? Int,
+ let height = properties[kCGImagePropertyPixelHeight] as? Int,
+ width == height, width >= 32, width <= 8192 else { return nil }
+ return ArtworkStore.downsample(data, maxPixelSize: 512, png: true)
+ }
+
+ private static func store(_ data: Data, image: URL, source: String, folder: URL) -> Bool {
+ do {
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ try data.write(to: image, options: .atomic)
+ try? Data(source.utf8).write(to: folder.appendingPathComponent("square-source.txt"), options: .atomic)
+ return true
+ } catch { return false }
+ }
+}
diff --git a/Sources/NXLibraryCore/Services/BuildEvent.swift b/Sources/NXLibraryCore/Services/BuildEvent.swift
index 4fbfbb9..a8d2f93 100644
--- a/Sources/NXLibraryCore/Services/BuildEvent.swift
+++ b/Sources/NXLibraryCore/Services/BuildEvent.swift
@@ -1,7 +1,7 @@
public enum BuildEvent: Equatable {
case stage(k: Int, n: Int, name: String, step: String?)
case step(String)
- case progress(fraction: Double, done: Double?, total: Double?, unit: String?, eta: Double?)
+ case progress(fraction: Double, done: Double?, total: Double?, unit: String?, eta: Double?, elapsed: Double? = nil)
/// `warn`: the stage finished without what it makes, though `ok` (a pipeline archive that failed, so the app is
/// packed without one). Engines before `warn` existed never send it.
case stageEnd(name: String, ok: Bool, elapsed: Double?, summary: String?, warn: Bool = false)
diff --git a/Sources/NXLibraryCore/Services/BuildEventParser.swift b/Sources/NXLibraryCore/Services/BuildEventParser.swift
index 697700f..b5ec99f 100644
--- a/Sources/NXLibraryCore/Services/BuildEventParser.swift
+++ b/Sources/NXLibraryCore/Services/BuildEventParser.swift
@@ -33,7 +33,7 @@ public enum BuildEventParser {
return wire.step.map { .step($0) }
case "progress":
return .progress(fraction: wire.frac ?? 0, done: wire.done,
- total: wire.total, unit: wire.unit, eta: wire.eta)
+ total: wire.total, unit: wire.unit, eta: wire.eta, elapsed: wire.elapsed)
case "stage_end":
return .stageEnd(name: wire.name ?? "", ok: wire.ok ?? true,
elapsed: wire.elapsed, summary: wire.summary, warn: wire.warn ?? false)
diff --git a/Sources/NXLibraryCore/Services/PipelineArchiveProgress.swift b/Sources/NXLibraryCore/Services/PipelineArchiveProgress.swift
new file mode 100644
index 0000000..85b6948
--- /dev/null
+++ b/Sources/NXLibraryCore/Services/PipelineArchiveProgress.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+/// Reads only the engine's current pipeline log. A batch counts when the engine reports it ready;
+/// compiler temporaries and old cache keys do not prove that this build has completed a pipeline.
+/// Unknown log formats leave the stage indeterminate. No upstream files are changed.
+public struct PipelineArchiveProgress: Sendable {
+ public init() {}
+
+ public func poll(logURL: URL, architectures: [String], stageStartedAt: Date,
+ now: Date = Date()) -> BuildEvent? {
+ guard !architectures.isEmpty,
+ let attrs = try? FileManager.default.attributesOfItem(atPath: logURL.path),
+ let modified = attrs[.modificationDate] as? Date, modified >= stageStartedAt,
+ let size = (attrs[.size] as? NSNumber)?.intValue, size <= 4 * 1024 * 1024,
+ let log = try? String(contentsOf: logURL, encoding: .utf8) else { return nil }
+ var totals: [String: Int] = [:]
+ var batches: [String: Int] = [:]
+ var ready = Set()
+ var compiled = Set()
+ // Python's parallel compute workers may join two print calls on one line. Keep both
+ // records, but do not consume the final physical line until the writer finishes it.
+ let records = log.components(separatedBy: "\n").dropLast().flatMap { line in
+ line.components(separatedBy: "pso_offline:").dropFirst().map { "pso_offline:" + $0 }
+ }
+ for line in records {
+ let words = line.split(whereSeparator: \.isWhitespace).map(String.init)
+ guard words.count >= 4, words[0] == "pso_offline:" else { continue }
+ let arch = String(words[1].dropLast())
+ guard architectures.contains(arch) else { continue }
+ if words[2] == "metal-tt,", words.count >= 7,
+ let bases = Int(words[3]), let computes = Int(words[5]),
+ bases >= 0, computes >= 0, bases <= 10_000_000, computes <= 10_000_000 {
+ totals[arch] = bases + computes
+ } else if words[2] == "batch", words.count >= 6 {
+ let key = arch + ":" + words[3]
+ if let count = Int(words[4]), count > 0, count <= 10_000_000,
+ words[5] == "bases," || words[5] == "compute" {
+ batches[key] = count
+ } else if words[4] == "ready", words[5] == "(compiled)" || words[5] == "(reused)" {
+ ready.insert(key)
+ if words[5] == "(compiled)" { compiled.insert(key) }
+ }
+ }
+ }
+ guard let perArchitecture = totals.values.first, perArchitecture > 0 else { return nil }
+ // The same plan is compiled once for each requested GPU. Include GPUs not started yet.
+ let total = perArchitecture * Set(architectures).count
+ let done = min(total, ready.reduce(0) { $0 + (batches[$1] ?? 0) })
+ let newlyCompiled = compiled.reduce(0) { $0 + (batches[$1] ?? 0) }
+ let elapsed = max(0, now.timeIntervalSince(stageStartedAt))
+ let eta = newlyCompiled > 0 && elapsed >= 3 && done < total
+ ? Double(total - done) * elapsed / Double(newlyCompiled) : nil
+ return .progress(fraction: min(0.99, Double(done) / Double(total)), done: Double(done),
+ total: Double(total), unit: "pipelines", eta: eta)
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/AppIconPreferenceTests.swift b/Tests/NXLibraryCoreTests/AppIconPreferenceTests.swift
new file mode 100644
index 0000000..c6e2289
--- /dev/null
+++ b/Tests/NXLibraryCoreTests/AppIconPreferenceTests.swift
@@ -0,0 +1,97 @@
+import XCTest
+@testable import NXLibraryCore
+
+final class AppIconPreferenceTests: XCTestCase {
+ private func game() -> PortedGame {
+ PortedGame(name: "Game", url: URL(fileURLWithPath: "/tmp/Game.app"), platform: .macApp,
+ sizeBytes: 1, modificationDate: Date(timeIntervalSince1970: 0))
+ }
+
+ func testAutomaticPreservesBothExplicitDestinationsAcrossRebuildAndRelaunch() throws {
+ for onMac in [true, false] {
+ var g = game()
+ g.appIconOnMac = onMac
+ g.usesPickedAppIcon = onMac
+ g.useAutomaticAppIcon()
+ g.recordInstalledBuild(BuildRecord(), toolchain: ToolchainInfo(), at: g.url, version: "2.0", date: Date())
+ let restored = try JSONDecoder().decode(PortedGame.self, from: JSONEncoder().encode(g))
+ XCTAssertEqual(restored.appIconOnMac, onMac)
+ XCTAssertEqual(restored.wantsAutomaticAppIcon(hasPick: false, hasOriginalIcon: false), onMac)
+ XCTAssertFalse(restored.usesPickedAppIcon)
+ XCTAssertTrue(restored.appIconPending)
+ }
+ }
+
+ func testAutomaticRetainsLegacyMacOptInWithoutOverridingExplicitOnlyHere() {
+ var legacy = game()
+ legacy.usesPickedAppIcon = true
+ legacy.useAutomaticAppIcon()
+ XCTAssertEqual(legacy.appIconOnMac, true)
+ XCTAssertFalse(legacy.usesPickedAppIcon)
+
+ legacy.appIconOnMac = false
+ legacy.usesPickedAppIcon = true
+ legacy.useAutomaticAppIcon()
+ XCTAssertEqual(legacy.appIconOnMac, false)
+ }
+
+ func testOnlyHereOverridesAutomaticIconForAnAppWithNoOriginalIcon() {
+ var g = game()
+ g.appIconOnMac = false
+ XCTAssertFalse(g.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: false))
+ XCTAssertFalse(g.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: true))
+ }
+
+ func testUseOnMacRequiresAnArtworkPickAndSurvivesRebuilds() throws {
+ var g = game()
+ g.appIconOnMac = true
+ g.recordInstalledBuild(BuildRecord(), toolchain: ToolchainInfo(), at: g.url, version: "2.0", date: Date())
+ let restored = try JSONDecoder().decode(PortedGame.self, from: JSONEncoder().encode(g))
+ XCTAssertTrue(restored.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: false))
+ XCTAssertTrue(restored.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: true))
+ XCTAssertFalse(restored.wantsPickedAppIcon(hasPick: false, hasOriginalIcon: false))
+ }
+
+ func testExplicitOnlyHerePersistsAcrossRelaunchAndOverridesLegacyTrue() throws {
+ var g = game()
+ g.usesPickedAppIcon = true
+ g.appIconOnMac = false
+ g.recordInstalledBuild(BuildRecord(), toolchain: ToolchainInfo(), at: g.url, version: "2.0", date: Date())
+ let restored = try JSONDecoder().decode(PortedGame.self, from: JSONEncoder().encode(g))
+ XCTAssertEqual(restored.appIconOnMac, false)
+ XCTAssertFalse(restored.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: false))
+ }
+
+ func testLibraryRescanPreservesOnlyHereRatherThanTakingScannedDefaults() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = LibraryStore(directory: dir)
+ var saved = game()
+ saved.appIconOnMac = false
+ try store.save(LibraryFile(games: [saved]))
+ let merged = try XCTUnwrap(try store.merge(scanned: [game()]).games.first)
+ XCTAssertEqual(merged.appIconOnMac, false)
+ XCTAssertFalse(merged.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: false))
+ }
+
+ func testAutomaticFallbackNeverOverridesAPickOriginalIconOrOnlyHere() {
+ var g = game()
+ XCTAssertTrue(g.wantsAutomaticAppIcon(hasPick: false, hasOriginalIcon: false))
+ XCTAssertFalse(g.wantsAutomaticAppIcon(hasPick: true, hasOriginalIcon: false))
+ XCTAssertFalse(g.wantsAutomaticAppIcon(hasPick: false, hasOriginalIcon: true))
+ g.appIconOnMac = false
+ XCTAssertFalse(g.wantsAutomaticAppIcon(hasPick: false, hasOriginalIcon: false))
+ }
+
+ func testOlderLibrariesKeepTheirBehaviorUntilThePlayerMakesAChoice() throws {
+ var json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(game())) as? [String: Any])
+ json.removeValue(forKey: "appIconOnMac")
+ let old = try JSONDecoder().decode(PortedGame.self, from: JSONSerialization.data(withJSONObject: json))
+ XCTAssertNil(old.appIconOnMac)
+ XCTAssertTrue(old.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: false))
+ XCTAssertFalse(old.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: true))
+ var optedIn = old
+ optedIn.usesPickedAppIcon = true
+ XCTAssertTrue(optedIn.wantsPickedAppIcon(hasPick: true, hasOriginalIcon: true))
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/AppIconProgressTests.swift b/Tests/NXLibraryCoreTests/AppIconProgressTests.swift
new file mode 100644
index 0000000..cc93df4
--- /dev/null
+++ b/Tests/NXLibraryCoreTests/AppIconProgressTests.swift
@@ -0,0 +1,17 @@
+import XCTest
+@testable import NXLibraryCore
+
+final class AppIconProgressTests: XCTestCase {
+ func testMissingIconAddsAnInformationalResultWithoutReplacingBuildOutput() throws {
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: folder) }
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ let log = folder.appendingPathComponent("build.log")
+ try Data("engine: build completed\n".utf8).write(to: log)
+ try AppIconProgress.unavailable.appendToBuildLog(log)
+ let text = try String(contentsOf: log, encoding: .utf8)
+ XCTAssertTrue(text.hasPrefix("engine: build completed\n"))
+ XCTAssertTrue(text.contains("Game ready. No icon found"))
+ XCTAssertFalse(text.lowercased().contains("build failed"))
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/AppIconWriterTests.swift b/Tests/NXLibraryCoreTests/AppIconWriterTests.swift
index 5827344..b1a983c 100644
--- a/Tests/NXLibraryCoreTests/AppIconWriterTests.swift
+++ b/Tests/NXLibraryCoreTests/AppIconWriterTests.swift
@@ -7,13 +7,15 @@ final class AppIconWriterTests: XCTestCase {
var calls: [[String]] = []
/// Exit status for each codesign call in turn; 0 once the list runs out.
var codesignStatus: [Int32] = []
+ var iconOutputs: [Data] = []
func run(_ executable: URL, _ arguments: [String], environment: [String: String]?,
currentDirectory: URL?) throws -> CommandResult {
calls.append([executable.lastPathComponent] + arguments)
switch executable.lastPathComponent {
case "iconutil":
- try Data("icns".utf8).write(to: URL(fileURLWithPath: arguments[4]))
+ let output = iconOutputs.isEmpty ? Data("icns".utf8) : iconOutputs.removeFirst()
+ try output.write(to: URL(fileURLWithPath: arguments[4]))
return CommandResult(status: 0, stdout: "", stderr: "")
case "codesign":
let status = codesignStatus.isEmpty ? 0 : codesignStatus.removeFirst()
@@ -99,6 +101,30 @@ final class AppIconWriterTests: XCTestCase {
XCTAssertEqual(try info(app)["CFBundleIdentifier"] as? String, "org.nxnative.test")
}
+ func testMissingReplacementRemovesThePreviousPickAndKeepsAnIconlessAppUsable() throws {
+ let app = try makeApp()
+ try writer.apply(image: png, to: app, key: "game")
+ XCTAssertTrue(writer.showsWrittenIcon(app))
+ XCTAssertTrue(try writer.update(image: nil, app: app, key: "game"))
+ XCTAssertNil(icon(app))
+ XCTAssertNil(try info(app)["CFBundleIconFile"])
+ XCTAssertFalse(writer.showsWrittenIcon(app))
+ XCTAssertTrue(FileManager.default.fileExists(atPath: app.appendingPathComponent("Contents/MacOS/nxnative").path))
+ XCTAssertFalse(try writer.update(image: nil, app: app, key: "game"))
+ }
+
+ func testAutomaticReadsTheOriginalInsteadOfTheAppliedPick() throws {
+ let app = try makeApp(icon: "original")
+ let before = try XCTUnwrap(writer.originalIconURL(app, key: "game"))
+ XCTAssertEqual(try Data(contentsOf: before), Data("original".utf8))
+ try writer.apply(image: png, to: app, key: "game")
+ let backedUp = try XCTUnwrap(writer.originalIconURL(app, key: "game"))
+ XCTAssertEqual(try Data(contentsOf: backedUp), Data("original".utf8))
+ XCTAssertNotEqual(backedUp, before)
+ XCTAssertTrue(try writer.update(image: nil, app: app, key: "game"))
+ XCTAssertEqual(icon(app), "original")
+ }
+
func testOwnIconIsKeptAndRestored() throws {
let app = try makeApp(icon: "original")
XCTAssertTrue(writer.hasOwnIcon(app))
@@ -114,6 +140,33 @@ final class AppIconWriterTests: XCTestCase {
XCTAssertTrue(writer.hasOwnIcon(app))
}
+ func testChangingFromAToBReplacesTheIconAndKeepsTheGamesOriginal() throws {
+ let app = try makeApp(icon: "game original")
+ runner.iconOutputs = [Data("icon A".utf8), Data("icon B".utf8)]
+ try writer.apply(image: png, to: app, key: "0100")
+ XCTAssertEqual(icon(app), "icon A")
+ try writer.apply(image: png, to: app, key: "0100")
+ XCTAssertEqual(icon(app), "icon B")
+ XCTAssertEqual(try String(contentsOf: root.appendingPathComponent("backups/0100/original.icns")), "game original")
+ try writer.restore(app, key: "0100")
+ XCTAssertEqual(icon(app), "game original", "Automatic must not restore A instead of the game's original")
+ }
+
+ func testAutomaticThenAnotherPickStillRestoresAbsenceForAnIconlessGame() throws {
+ let app = try makeApp()
+ runner.iconOutputs = [Data("icon A".utf8), Data("icon B".utf8)]
+ try writer.apply(image: png, to: app, key: "0400")
+ XCTAssertEqual(icon(app), "icon A")
+ try writer.restore(app, key: "0400")
+ XCTAssertNil(icon(app))
+ try writer.apply(image: png, to: app, key: "0400")
+ XCTAssertEqual(icon(app), "icon B")
+ try writer.restore(app, key: "0400")
+ XCTAssertNil(icon(app))
+ XCTAssertFalse(writer.showsWrittenIcon(app))
+ XCTAssertFalse(writer.hasOriginalIcon(app, key: "0400"))
+ }
+
func testRebuiltAppReplacesTheKeptOriginal() throws {
_ = try makeApp(icon: "old build")
try writer.apply(image: png, to: root.appendingPathComponent("Game.app"), key: "0100")
@@ -137,7 +190,7 @@ final class AppIconWriterTests: XCTestCase {
XCTAssertEqual(try String(contentsOf: app.appendingPathComponent("Contents/MacOS/nxnative")), "launcher")
}
- func testOriginalIconDecidesWhetherToAsk() throws {
+ func testOriginalIconIsRememberedIncludingItsAbsence() throws {
let own = try makeApp(icon: "original")
XCTAssertTrue(writer.hasOriginalIcon(own, key: "0100"))
try writer.apply(image: png, to: own, key: "0100")
diff --git a/Tests/NXLibraryCoreTests/AppUpdateTests.swift b/Tests/NXLibraryCoreTests/AppUpdateTests.swift
index cf4068f..c2a2fd2 100644
--- a/Tests/NXLibraryCoreTests/AppUpdateTests.swift
+++ b/Tests/NXLibraryCoreTests/AppUpdateTests.swift
@@ -185,6 +185,16 @@ final class AppUpdateTests: XCTestCase {
XCTAssertNil(AppUpdate.neededXcode(r, installedXcode: "27.2.1"))
}
+ /// The app reads the installed Xcode as `xcodebuild -version`'s first line, "Xcode 27.2": the word before
+ /// the number must not make a current Xcode look older (0.1.4 asked to update Xcode 27.2 to 27.2).
+ func testRequirementNotesReadXcodebuildsOwnLine() {
+ let r = AppUpdate.Requirements(minimumXcode: "27.2", engineCommit: nil)
+ XCTAssertNil(AppUpdate.neededXcode(r, installedXcode: "Xcode 27.2"))
+ XCTAssertNil(AppUpdate.neededXcode(r, installedXcode: "Xcode 27.3"))
+ XCTAssertEqual(AppUpdate.neededXcode(r, installedXcode: "Xcode 27.1"), "27.2")
+ XCTAssertEqual(AppUpdate.requirementNotes(r, installedXcode: "Xcode 27.2", currentEngine: "x"), [])
+ }
+
/// scripts/release.sh reads these two values from the sources with sed; a reformat must not break it.
func testReleaseScriptCanReadTheRequirements() throws {
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
diff --git a/Tests/NXLibraryCoreTests/AutomaticIconTests.swift b/Tests/NXLibraryCoreTests/AutomaticIconTests.swift
new file mode 100644
index 0000000..6b47f47
--- /dev/null
+++ b/Tests/NXLibraryCoreTests/AutomaticIconTests.swift
@@ -0,0 +1,116 @@
+import XCTest
+import AppKit
+@testable import NXLibraryCore
+
+final class AutomaticIconTests: XCTestCase {
+ private let switch1 = "0100F2C0115B6000"
+ private let switch2 = "0400486024DEC000"
+ private let html = """
+  | Hollow Knight |
+ ![]() | Hollow Knight - Nintendo Switch 2 Edition |
+ ![]() | Hollow Knight: Silksong |
+ """
+
+ func testExactGameWinsRatherThanFirstResultOrASequel() {
+ let results = SwitchIconShowdown.results(in: html)
+ XCTAssertEqual(SwitchIconShowdown.match(name: "Hollow Knight", titleID: switch1, results: results)?.id, 828)
+ XCTAssertEqual(SwitchIconShowdown.match(name: "Hollow Knight - Nintendo Switch 2 Edition", titleID: switch2, results: results)?.id, 13932)
+ XCTAssertNil(SwitchIconShowdown.match(name: "Hollow Knight", titleID: switch1,
+ results: [SwitchIconShowdown.Result(id: 1, title: "Hollow Knight"), SwitchIconShowdown.Result(id: 2, title: "Hollow Knight")]), "Duplicate titles with different IDs are ambiguous")
+ XCTAssertNil(SwitchIconShowdown.match(name: "Hollow Knight", titleID: switch2, results: results), "Switch 2 Title ID alone does not identify the edition")
+ XCTAssertNil(SwitchIconShowdown.match(name: "Hollow Knight", titleID: switch1, results: Array(results.suffix(1))))
+ }
+
+ func testNormalizationAndMalformedResults() {
+ let text = "![]() | Pokémon & Friends | "
+ XCTAssertEqual(SwitchIconShowdown.match(name: "Pokemon & Friends", titleID: switch2, results: SwitchIconShowdown.results(in: text))?.id, 12)
+ XCTAssertTrue(SwitchIconShowdown.results(in: "").isEmpty)
+ XCTAssertNil(SwitchIconShowdown.match(name: "", titleID: switch2, results: SwitchIconShowdown.results(in: text)))
+ }
+
+ func testSpecificRobotsOptOutOverridesGeneralAllowAndUnknownPolicyIsDenied() {
+ XCTAssertFalse(SwitchIconShowdown.allowsIcons(in: "unavailable", path: "/images/828.png"))
+ XCTAssertFalse(SwitchIconShowdown.allowsIcons(in: "User-agent: *\nAllow: /images/\nUser-agent: NXLibrary\nDisallow: /\n", path: "/images/828.png"))
+ XCTAssertTrue(SwitchIconShowdown.allowsIcons(in: "User-agent: *\nDisallow: /images/\nAllow: /images/828.png\n", path: "/images/828.png"))
+ }
+
+ private final class Client: ArtworkHTTPClient {
+ var requests: [URL] = []
+ var primaryStatus = 404
+ var robots = "User-agent: *\nAllow: /\n"
+ var searchHTML = ""
+ var image: Data = Data()
+ var offline = false
+ func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
+ let url = request.url!
+ requests.append(url)
+ if offline { throw URLError(.notConnectedToInternet) }
+ let data: Data, status: Int
+ if url.host == "api.nlib.cc" { data = image; status = primaryStatus }
+ else if url.path == "/robots.txt" { data = Data(robots.utf8); status = 200 }
+ else if url.path == "/search.php" { data = Data(searchHTML.utf8); status = 200 }
+ else { data = image; status = 200 }
+ return (data, HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)!)
+ }
+ }
+
+ private func folder() -> URL {
+ let f = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ addTeardownBlock { try? FileManager.default.removeItem(at: f) }
+ return f
+ }
+
+ private func png() -> Data {
+ let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 64, pixelsHigh: 64, bitsPerSample: 8,
+ samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
+ return rep.representation(using: .png, properties: [:])!
+ }
+
+ func testPrimaryIconStopsTheFallback() async {
+ let c = Client(); c.primaryStatus = 200; c.image = png()
+ let store = ArtworkStore(cacheDirectory: folder(), client: c)
+ let url = await store.automaticIcon(titleID: switch1, name: "Hollow Knight")
+ XCTAssertNotNil(url)
+ XCTAssertEqual(c.requests.map(\.host), ["api.nlib.cc"])
+ }
+
+ func testMissingPrimaryUsesMatchedIconAndCachesForOfflineUse() async {
+ let c = Client(); c.searchHTML = html; c.image = png()
+ let store = ArtworkStore(cacheDirectory: folder(), client: c)
+ let first = await store.automaticIcon(titleID: switch1, name: "Hollow Knight")
+ XCTAssertNotNil(first)
+ XCTAssertEqual(c.requests.last?.path, "/images/828.png")
+ c.offline = true
+ let again = await store.automaticIcon(titleID: switch1, name: "Hollow Knight")
+ XCTAssertEqual(first, again)
+ XCTAssertEqual(c.requests.count, 4)
+ }
+
+ func testAmbiguousAndUnavailableSourcesLeaveNoIconAndDoNotRetryImmediately() async {
+ let c = Client(); c.searchHTML = html; c.image = png()
+ let store = ArtworkStore(cacheDirectory: folder(), client: c)
+ let first = await store.automaticIcon(titleID: switch2, name: "Hollow Knight")
+ XCTAssertNil(first)
+ let second = await store.automaticIcon(titleID: switch2, name: "Hollow Knight")
+ XCTAssertNil(second)
+ XCTAssertEqual(c.requests.count, 3)
+ XCTAssertTrue(c.requests.allSatisfy { !$0.path.hasPrefix("/images/") })
+ }
+
+ func testSiteOptOutStopsAutomaticImageDownloads() async {
+ let c = Client(); c.searchHTML = html; c.image = png()
+ c.robots = "User-agent: *\nDisallow: /images/\n"
+ let store = ArtworkStore(cacheDirectory: folder(), client: c)
+ let result = await store.automaticIcon(titleID: switch1, name: "Hollow Knight")
+ XCTAssertNil(result)
+ XCTAssertTrue(c.requests.allSatisfy { !$0.path.hasPrefix("/images/") })
+ }
+
+ func testInvalidImageIsNotCachedAsAnIcon() async {
+ let c = Client(); c.primaryStatus = 200; c.searchHTML = html; c.image = Data("not an image".utf8)
+ let store = ArtworkStore(cacheDirectory: folder(), client: c)
+ let result = await store.automaticIcon(titleID: switch1, name: "Hollow Knight")
+ XCTAssertNil(result)
+ XCTAssertNil(store.cachedURL(titleID: switch1, kind: .square))
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/BuildEventParserTests.swift b/Tests/NXLibraryCoreTests/BuildEventParserTests.swift
index ba28e41..3f5b88d 100644
--- a/Tests/NXLibraryCoreTests/BuildEventParserTests.swift
+++ b/Tests/NXLibraryCoreTests/BuildEventParserTests.swift
@@ -9,7 +9,11 @@ final class BuildEventParserTests: XCTestCase {
)
XCTAssertEqual(
BuildEventParser.parse(#"{"ev":"progress","frac":0.5,"done":1,"total":2,"unit":"GB","eta":10}"#),
- .progress(fraction: 0.5, done: 1, total: 2, unit: "GB", eta: 10)
+ .progress(fraction: 0.5, done: 1, total: 2, unit: "GB", eta: 10, elapsed: nil)
+ )
+ XCTAssertEqual(
+ BuildEventParser.parse(#"{"ev":"progress","frac":0.5,"done":1,"total":2,"unit":"GB","eta":10,"elapsed":4.5}"#),
+ .progress(fraction: 0.5, done: 1, total: 2, unit: "GB", eta: 10, elapsed: 4.5)
)
}
diff --git a/Tests/NXLibraryCoreTests/IconLaunchWaitTests.swift b/Tests/NXLibraryCoreTests/IconLaunchWaitTests.swift
new file mode 100644
index 0000000..55994e0
--- /dev/null
+++ b/Tests/NXLibraryCoreTests/IconLaunchWaitTests.swift
@@ -0,0 +1,34 @@
+import XCTest
+@testable import NXLibraryCore
+
+final class IconLaunchWaitTests: XCTestCase {
+ func testOnlyTheRequestedGamesFinalSuccessfulUpdateMayLaunch() {
+ let game = UUID(), notice = UUID()
+ var wait = IconLaunchWait()
+ wait.begin(gameID: game, noticeID: notice)
+ XCTAssertNil(wait.finished(gameID: UUID(), rerun: false, succeeded: true, visibleNoticeID: notice))
+ XCTAssertNil(wait.finished(gameID: game, rerun: true, succeeded: true, visibleNoticeID: notice))
+ XCTAssertEqual(wait.finished(gameID: game, rerun: false, succeeded: true, visibleNoticeID: notice), game)
+ XCTAssertNil(wait.gameID)
+ XCTAssertNil(wait.finished(gameID: game, rerun: false, succeeded: true, visibleNoticeID: notice))
+ }
+
+ func testCancelPreventsLaunchingWhenSigningEventuallyEnds() {
+ let game = UUID(), notice = UUID()
+ var wait = IconLaunchWait()
+ wait.begin(gameID: game, noticeID: notice)
+ wait.cancel()
+ XCTAssertNil(wait.finished(gameID: game, rerun: false, succeeded: true, visibleNoticeID: notice))
+ }
+
+ func testFailureOrDismissedNoticeDoesNotLaunch() {
+ let game = UUID(), notice = UUID()
+ var wait = IconLaunchWait()
+ wait.begin(gameID: game, noticeID: notice)
+ XCTAssertNil(wait.finished(gameID: game, rerun: false, succeeded: false, visibleNoticeID: notice))
+ XCTAssertNil(wait.gameID)
+ wait.begin(gameID: game, noticeID: notice)
+ XCTAssertNil(wait.finished(gameID: game, rerun: false, succeeded: true, visibleNoticeID: UUID()))
+ XCTAssertNil(wait.gameID)
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/NXNativeGameSettingsTests.swift b/Tests/NXLibraryCoreTests/NXNativeGameSettingsTests.swift
index 95f32dd..1e06b89 100644
--- a/Tests/NXLibraryCoreTests/NXNativeGameSettingsTests.swift
+++ b/Tests/NXLibraryCoreTests/NXNativeGameSettingsTests.swift
@@ -150,4 +150,30 @@ final class NXNativeGameSettingsTests: XCTestCase {
try s.resetAll()
XCTAssertEqual(f.store.values[domain] ?? [:], [:])
}
+
+ func test120FPSChoiceSurvivesOtherOptionsAndLaunchMigration() throws {
+ let f = try fixture()
+ let file = f.game.url.appendingPathComponent("Contents/Resources/dm/launcher.json")
+ var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any])
+ var rows = try XCTUnwrap(json["settings"] as? [[String: Any]])
+ rows.append(["env": "DM_FPS_TARGET", "kind": "choice", "section": "Game", "label": "Frame rate",
+ "default": "60", "options": [["30", "30 fps"], ["60", "60 fps"], ["120", "120 fps"]]])
+ json["settings"] = rows
+ try JSONSerialization.data(withJSONObject: json).write(to: file)
+
+ let s = try settings(f)
+ try s.set("DM_FPS_TARGET", to: "120")
+ let before = s.runtimeConfig() // Legacy typed defaults represent both 60 and 120 as enable60FPS.
+ var changed = before
+ changed.fullscreen = !before.fullscreen
+ try s.apply(changed, changedFrom: before)
+
+ let reopened = try settings(f)
+ try reopened.migrate() // The same migration used before launching a saved-choices game.
+ XCTAssertEqual(reopened.snapshot().value("DM_FPS_TARGET"), "120")
+ XCTAssertEqual(f.store.values[domain]?["DM_FPS_TARGET"], "120")
+ let fps = try XCTUnwrap(reopened.snapshot().setting("DM_FPS_TARGET"))
+ XCTAssertEqual(GameSettingValues.label(fps, "120"), "120 fps")
+ XCTAssertEqual(GameSettingValues.stepped(fps, from: "60", by: 1), "120")
+ }
}
diff --git a/Tests/NXLibraryCoreTests/PipelineArchiveProgressTests.swift b/Tests/NXLibraryCoreTests/PipelineArchiveProgressTests.swift
new file mode 100644
index 0000000..d881f72
--- /dev/null
+++ b/Tests/NXLibraryCoreTests/PipelineArchiveProgressTests.swift
@@ -0,0 +1,89 @@
+import XCTest
+@testable import NXLibraryCore
+
+final class PipelineArchiveProgressTests: XCTestCase {
+ private let start = Date(timeIntervalSince1970: 1000)
+ private let arch = "applegpu_g17s"
+
+ private func poll(_ text: String, age: TimeInterval = 30, stale: Bool = false) throws -> BuildEvent? {
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: folder) }
+ let log = folder.appendingPathComponent("pso_offline_mac.log")
+ try text.write(to: log, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.modificationDate: start.addingTimeInterval(stale ? -60 : age)], ofItemAtPath: log.path)
+ return PipelineArchiveProgress().poll(logURL: log, architectures: [arch], stageStartedAt: start, now: start.addingTimeInterval(age))
+ }
+
+ private var header: String {
+ "pso_offline: \(arch): metal-tt, 2500 bases, 32 compute pipelines (minutes) ...\n"
+ }
+
+ func testCountsOnlyBatchesReportedReadyNotStartedOrTemporaryFiles() throws {
+ let text = header + """
+ pso_offline: applegpu_g17s: batch 1/5: 1000 bases, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 1/5: ready (compiled)
+ pso_offline: applegpu_g17s: batch 2/5: 1000 bases, compiling or checking cache
+ """ + "\n"
+ let event = try poll(text)
+ guard case .progress(let fraction, let done, let total, let unit, let eta, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 1000)
+ XCTAssertEqual(total, 2532)
+ XCTAssertEqual(unit, "pipelines")
+ XCTAssertEqual(fraction, 1000.0 / 2532, accuracy: 0.0001)
+ XCTAssertEqual(try XCTUnwrap(eta), 45.96, accuracy: 0.01)
+ }
+
+ func testReusedBatchesCountButSupplyNoCompilationRate() throws {
+ let event = try poll(header + """
+ pso_offline: applegpu_g17s: batch 1/5: 1000 bases, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 1/5: ready (reused)
+ """ + "\n")
+ guard case .progress(_, let done, _, _, let eta, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 1000)
+ XCTAssertNil(eta)
+ }
+
+ func testStaleLogDoesNotReportLastBuildsProgress() throws {
+ XCTAssertNil(try poll(header, stale: true))
+ }
+
+ func testTruncatedUnknownAndPartialLinesDoNotInventCounts() throws {
+ XCTAssertNil(try poll("a different engine's log\n"))
+ let event = try poll(header + "pso_offline: applegpu_g17s: batch 1/5: 1000 bases, compiling or checking cache\n" + "pso_offline: applegpu_g17s: batch 1/5: ready (compiled)")
+ guard case .progress(_, let done, _, _, _, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 0, "The writer has not finished this line yet")
+ }
+
+ func testParallelComputeBatchesAndCustomBatchSizesUseActualCounts() throws {
+ let event = try poll(header + """
+ pso_offline: applegpu_g17s: batch 1/3: 2500 bases, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 1/3: ready (compiled)
+ pso_offline: applegpu_g17s: batch 2/3: 16 compute pipelines, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 3/3: 16 compute pipelines, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 3/3: ready (compiled)
+ pso_offline: applegpu_g17s: batch 3/3: ready (compiled)
+ """ + "\n")
+ guard case .progress(_, let done, _, _, _, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 2516, "Duplicate ready lines must not double count")
+ }
+
+ func testParallelCompilerMessagesSharingALineDoNotLoseABatch() throws {
+ let event = try poll(header + """
+ pso_offline: applegpu_g17s: batch 4/5: 16 compute pipelines, compiling or checking cachepso_offline: applegpu_g17s: batch 5/5: 16 compute pipelines, compiling or checking cache
+ pso_offline: applegpu_g17s: batch 4/5: ready (compiled)
+ pso_offline: applegpu_g17s: batch 5/5: ready (compiled)
+ """ + "\n")
+ guard case .progress(_, let done, _, _, _, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 32)
+ }
+
+ func testOtherArchitecturesDoNotCountTowardThisMac() throws {
+ let event = try poll(header + """
+ pso_offline: applegpu_g18p: batch 1/1: 2532 bases, compiling or checking cache
+ pso_offline: applegpu_g18p: batch 1/1: ready (compiled)
+ """ + "\n")
+ guard case .progress(_, let done, _, _, _, _) = event else { return XCTFail("Missing progress") }
+ XCTAssertEqual(done, 0)
+ }
+}
diff --git a/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift b/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift
index c6f7d85..af5e9c3 100644
--- a/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift
+++ b/Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift
@@ -23,6 +23,7 @@ final class PortingFailureReasonTests: XCTestCase {
("games.several", .severalGames, "[RED] more than one game in the folder, keep one game per folder: 0100000000010000, 0100000000020000"),
("games.noTitleID", .noTitleID, "[RED] no game package with a readable content meta in /Users/x/Games/Foo"),
("disk.low", .diskFull, "[RED] less than 40 GB free on the volume of /Users/x/work"),
+ ("disk.app", .diskFull, "the app needs 12.4 GB on the volume of /Users/x/Temp (no APFS clones from the build there) and 13.1 GB are free"),
("disk.full", .diskFull, "OSError: [Errno 28] No space left on device"),
("icloud.detritus", .cloudFolder, "x.app: resource fork, Finder information, or similar detritus not allowed"),
]
@@ -103,6 +104,24 @@ final class PortingFailureReasonTests: XCTestCase {
XCTAssertEqual(classify("[RED] less than 40 GB free on the volume of /x").detail, "40")
}
+ func testPackAppDiskFullDetailIsRoundedGB() {
+ let log = """
+ the app needs 12.4 GB on the volume of /Users/x/Temp (no APFS clones from the build there) and 13.1 GB are free
+ [RED] pack_app.py exit code 1
+ """
+ let reason = classify(log, keys: "/unreadable/prod.keys", readable: false)
+ XCTAssertEqual(reason.kind, .diskFull)
+ XCTAssertEqual(reason.detail, "14")
+ XCTAssertEqual(reason.what(game: "Ocarina of Time"), "The porting needs at least 14 GB free on the work volume.")
+ XCTAssertEqual(reason.action, .openStorage)
+ }
+
+ func testSourcesWithoutKeysDoNotFallBackToKeysOnUnrecognizedFailure() {
+ let log = "some unknown crash or error"
+ let reason = classify(log, keys: "")
+ XCTAssertEqual(reason.kind, .unknown)
+ }
+
func testNoTitleIDDetailIsFolder() {
XCTAssertEqual(classify("[RED] no game package with a readable content meta in /Users/x/Foo").detail, "/Users/x/Foo")
// Engines before nxnative 5c4dc18f said it this way.
@@ -248,7 +267,7 @@ final class PortingFailureEngineTests: XCTestCase {
private func gitGrep(_ fragment: String, at commit: String, in repo: URL) -> Bool {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/git")
- p.arguments = ["-C", repo.path, "grep", "-q", "-F", fragment, commit, "--", "scripts", "dm/gfx/tools", "dm/tools"]
+ p.arguments = ["-C", repo.path, "grep", "-q", "-F", fragment, commit, "--", "scripts", "dm/gfx/tools", "dm/tools", "dm/mac"]
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
do { try p.run() } catch { return false }
diff --git a/Tests/NXLibraryCoreTests/PortingPresentationTests.swift b/Tests/NXLibraryCoreTests/PortingPresentationTests.swift
index 4e3cc93..27ef0b9 100644
--- a/Tests/NXLibraryCoreTests/PortingPresentationTests.swift
+++ b/Tests/NXLibraryCoreTests/PortingPresentationTests.swift
@@ -54,6 +54,53 @@ final class PortingPresentationTests: XCTestCase {
func testETAWhileFinishingCountsDownTheLastTime() {
XCTAssertEqual(PortingETA.text(finishing, lastFinishingSeconds: 150), "About 2 min left")
XCTAssertEqual(PortingETA.text(finishing, lastFinishingSeconds: nil), "Finishing up")
+
+ // Live ETA during Pipeline archive takes precedence
+ var pipelineArchive = finishing
+ pipelineArchive.apply(.progress(fraction: 0.5, done: 1000, total: 2000, unit: "pipelines", eta: 5400))
+ XCTAssertEqual(PortingETA.text(pipelineArchive, lastFinishingSeconds: 150), "Pipelines: about 1 hr 30 min")
+ XCTAssertEqual(PortingETA.text(pipelineArchive, lastFinishingSeconds: nil), "Pipelines: about 1 hr 30 min")
+ }
+
+ func testPipelineEstimateDoesNotClaimToIncludePackingTheApp() {
+ let p = progress([.stage(k: 5, n: 8, name: "Mac shaders", step: nil),
+ .stageEnd(name: "Mac shaders", ok: true, elapsed: 100, summary: nil),
+ .stage(k: 7, n: 8, name: "Pipeline archive", step: nil),
+ .progress(fraction: 0.5, done: 1000, total: 2000, unit: "pipelines", eta: 3600)])
+ XCTAssertEqual(PortingETA.text(p, lastFinishingSeconds: nil), "Pipelines: about 1 hr")
+ }
+
+ func testETAFormatsHoursAndMinutesWhenOverAnHour() {
+ let oneHour = progress([.stage(k: 5, n: 7, name: "Mac shaders", step: nil), .step("compiling"),
+ .progress(fraction: 0.1, done: nil, total: nil, unit: nil, eta: 3600)])
+ XCTAssertEqual(PortingETA.text(oneHour, lastFinishingSeconds: nil), "Shaders: about 1 hr")
+
+ let longShaders = progress([.stage(k: 5, n: 7, name: "Mac shaders", step: nil), .step("compiling"),
+ .progress(fraction: 0.5, done: nil, total: nil, unit: nil, eta: 15165)])
+ XCTAssertEqual(PortingETA.text(longShaders, lastFinishingSeconds: nil), "Shaders: about 4 hr 13 min")
+ XCTAssertEqual(PortingETA.text(longShaders, lastFinishingSeconds: 120), "About 4 hr 15 min left")
+
+ let longFinishing = progress([.stage(k: 5, n: 7, name: "Mac shaders", step: nil),
+ .stageEnd(name: "Mac shaders", ok: true, elapsed: 600, summary: nil),
+ .stage(k: 6, n: 7, name: "Mac host", step: nil)])
+ XCTAssertEqual(PortingETA.text(longFinishing, lastFinishingSeconds: 3600), "About 1 hr left")
+ XCTAssertEqual(PortingETA.text(longFinishing, lastFinishingSeconds: 3900), "About 1 hr 5 min left")
+ }
+
+ // MARK: - Stage Elapsed Time
+
+ func testElapsedFormatsSecondsMinutesAndHours() {
+ XCTAssertEqual(PortingETA.elapsed(0), "0 s")
+ XCTAssertEqual(PortingETA.elapsed(6), "6 s")
+ XCTAssertEqual(PortingETA.elapsed(59.4), "59 s")
+ XCTAssertEqual(PortingETA.elapsed(60), "1:00")
+ XCTAssertEqual(PortingETA.elapsed(61), "1:01")
+ XCTAssertEqual(PortingETA.elapsed(397), "6:37")
+ XCTAssertEqual(PortingETA.elapsed(3599), "59:59")
+ XCTAssertEqual(PortingETA.elapsed(3600), "1:00:00")
+ XCTAssertEqual(PortingETA.elapsed(3665), "1:01:05")
+ // 252 minutes 45 seconds (15165 s) -> 4 hours 12 minutes 45 seconds
+ XCTAssertEqual(PortingETA.elapsed(15165), "4:12:45")
}
// MARK: - Tile look
diff --git a/Tests/NXLibraryCoreTests/PortingProgressTests.swift b/Tests/NXLibraryCoreTests/PortingProgressTests.swift
index 55e7ae2..f5c1207 100644
--- a/Tests/NXLibraryCoreTests/PortingProgressTests.swift
+++ b/Tests/NXLibraryCoreTests/PortingProgressTests.swift
@@ -170,6 +170,29 @@ final class PortingProgressTests: XCTestCase {
let progress = run(beforeShaders + [.say("removing the shader cache of other chain versions")])
XCTAssertNil(PortingETA.text(progress, lastFinishingSeconds: nil))
}
+
+ func testPipelineArchiveProgressAdvancesOverallAndSetsFinishingETA() {
+ var progress = run(shaders + [
+ .stageEnd(name: "Mac shaders", ok: true, elapsed: 607, summary: nil),
+ .stage(k: 6, n: 8, name: "Mac host", step: "building"),
+ .stageEnd(name: "Mac host", ok: true, elapsed: 10, summary: nil),
+ .stage(k: 7, n: 8, name: "Pipeline archive", step: "applegpu_g13g")
+ ])
+ XCTAssertEqual(progress.phase, .finishing)
+ XCTAssertEqual(progress.overall, 0.9 + 0.1 / 3, accuracy: 1e-9) // 93.3%
+ XCTAssertNil(progress.finishingETA)
+
+ // Halfway through Pipeline archive
+ progress.apply(.progress(fraction: 0.5, done: 1000, total: 2000, unit: "pipelines", eta: 3600))
+ XCTAssertEqual(progress.finishingETA, 3600)
+ // overall should be 0.9 + 0.1 * (1 + 0.5) / 3 = 0.9 + 0.05 = 0.95 (95%)
+ XCTAssertEqual(progress.overall, 0.95, accuracy: 1e-9)
+
+ // Stage ends: clears finishingETA, overall becomes 0.9 + 0.1 * 2 / 3 = 0.9666...
+ progress.apply(.stageEnd(name: "Pipeline archive", ok: true, elapsed: 120, summary: nil))
+ XCTAssertNil(progress.finishingETA)
+ XCTAssertEqual(progress.overall, 0.9 + 0.2 / 3, accuracy: 1e-9)
+ }
}
final class BuildWarningTests: XCTestCase {
diff --git a/Tests/NXLibraryCoreTests/SessionSummaryTests.swift b/Tests/NXLibraryCoreTests/SessionSummaryTests.swift
index 25cfc65..dc9a164 100644
--- a/Tests/NXLibraryCoreTests/SessionSummaryTests.swift
+++ b/Tests/NXLibraryCoreTests/SessionSummaryTests.swift
@@ -33,8 +33,9 @@ final class SessionSummaryTests: XCTestCase {
func testUnderAMinuteSaysSo() throws {
let summary = try XCTUnwrap(SessionSummary(session: session(lasting: 42), game: game, total: 42))
- XCTAssertEqual(summary.thisSession, "Less than a minute")
- XCTAssertEqual(summary.total, "Less than a minute")
+ // As short as the other figures ("10m", "34h 5m"): the card's row has room for two of them and their labels.
+ XCTAssertEqual(summary.thisSession, "<1m")
+ XCTAssertEqual(summary.total, "<1m")
}
func testSessionsShorterThanTenSecondsShowNoCard() {
diff --git a/VERSION b/VERSION
index 473b31b..de629fc 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.4.1
+0.1.4.2
diff --git a/docs/architecture.md b/docs/architecture.md
index 51184ed..4eebc4a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,6 +1,6 @@
# NX Library — guida tecnica
-Aggiornata il 5 novembre 2026 sul codice del repository. La mappa comprende 253 file Swift di produzione e 104 file di test e supporto ai test. Il ramo porta nxnative a bd979ced (8 novembre 2026, 0.1.4.1; candidato in attesa di validazione manuale: rebuild di Ocarina of Time, che ritraduce gli shader). Il pin precedente, e5f03403, è stato confermato dal maintainer con un rebuild di Ocarina of Time; l'aggiunta di un gioco NSP con le chiavi resta da provare a mano. Le funzioni della 0.1.3 e della 0.1.4 aggiunte dopo l'ultima conferma restano in attesa di validazione manuale.
+Aggiornamento 9 novembre 2026 per NX Library 0.1.4.2. Il pin è `79741184`, con Metal 32023.922 / Xcode 27.2: adozione esplicitamente richiesta dal maintainer dopo la revisione di 71 commit. Gate e test automatici superati; una build/avvio manuale sull'esatto nuovo hash resta da confermare e non viene dichiarata eseguita. Ocarina installato è già su `7ffa63cc`, con la stessa catena shader e cinque commit di differenza. Vedi la [revisione engine](upstream-upgrades/2026-11-09-bd979ced-79741184-review.md) per evidenze, decisioni UI/backend e limiti.
Questa guida descrive il funzionamento attuale. Il [catalogo dei file](file-map.md) assegna una responsabilità a ogni file versionato, suddiviso per area.
@@ -117,7 +117,7 @@ Get ready mostra soltanto i sei requisiti tecnici, in una griglia 3×2; i proble
`CompatibilityChecker` verifica commit, modifiche del checkout, contratto CLI e compilatore Metal richiesto dal lock del motore. `ToolchainRequirements.current` contiene Xcode minimo, major .NET e asset Metal. `ToolchainGateCopy` produce la spiegazione del risultato per la UI.
-Il lock del pin attuale certifica Metal `32023.922`, asset Apple `27B47`, con Xcode minimo `27.2`. La [nota dell’upgrade del 6 novembre](upstream-upgrades/2026-11-06-4a0551e4-04df08d4-review.md) registra 04df08d4, confermato dal maintainer con un build di Ocarina of Time; quella [e5f03403 → bd979ced](upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md) registra il pin attuale, candidato in attesa di validazione manuale; quella [7fe9fc4d → e5f03403](upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [b2d46b86 → 7fe9fc4d](upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [04df08d4 → b2d46b86](upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md) registra b2d46b86, che aggiunge il controllo dei file `.zs` danneggiati in NUP e cartelle (riga `files.format` del classificatore); quella [del 5 novembre](upstream-upgrades/2026-11-05-b64a5ff0-4a0551e4-review.md) registra gate certificato senza override, build reale di Wonder con archivio pipeline e avvio confermato dall’utente per 4a0551e4. Un build con override riceve `toolchainVerified=false` e `--allow-other-metal`. Play valuta la disponibilità dell’app già installata separatamente dal gate per un nuovo porting.
+Il lock del pin attuale certifica Metal `32023.922`, asset Apple `27B47`, con Xcode minimo `27.2`. La [nota dell’upgrade del 6 novembre](upstream-upgrades/2026-11-06-4a0551e4-04df08d4-review.md) registra 04df08d4, confermato dal maintainer con un build di Ocarina of Time; quella [bd979ced → 79741184](upstream-upgrades/2026-11-09-bd979ced-79741184-review.md) registra il pin della 0.1.4.2, adottato su richiesta esplicita del maintainer con gate e test automatici superati ma build/avvio sull'esatto hash ancora non confermati; quella [e5f03403 → bd979ced](upstream-upgrades/2026-11-08-e5f03403-bd979ced-review.md) registra il pin della 0.1.4.1; quella [7fe9fc4d → e5f03403](upstream-upgrades/2026-11-07-7fe9fc4d-e5f03403-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [b2d46b86 → 7fe9fc4d](upstream-upgrades/2026-11-07-b2d46b86-7fe9fc4d-review.md) registra il pin precedente, confermato dal maintainer con un rebuild di Ocarina of Time; quella [04df08d4 → b2d46b86](upstream-upgrades/2026-11-06-04df08d4-b2d46b86-review.md) registra b2d46b86, che aggiunge il controllo dei file `.zs` danneggiati in NUP e cartelle (riga `files.format` del classificatore); quella [del 5 novembre](upstream-upgrades/2026-11-05-b64a5ff0-4a0551e4-review.md) registra gate certificato senza override, build reale di Wonder con archivio pipeline e avvio confermato dall’utente per 4a0551e4. Un build con override riceve `toolchainVerified=false` e `--allow-other-metal`. Play valuta la disponibilità dell’app già installata separatamente dal gate per un nuovo porting.
L'engine più nuovo è una scelta esplicita del giocatore (Settings → Engine → **Newest engine**). Dopo il controllo Upstream, che misura sempre i commit rispetto al pin, `NewestEngine.fetchLock` legge il lock di main da GitHub: se certifica un Metal diverso da quello del lock del pin, la riga dice che serve un NX Library più nuovo e non offre il cambio. `NewestEngineSwitcher` rifiuta un checkout con file modificati, scarica, rilegge il lock con git e solo allora fa `checkout --detach`. La scelta (`NewestEngineChoice`) è salvata, diventa il commit atteso dal gate e quello registrato nelle build, accende l'override (che resta bloccato finché la scelta è attiva) e marca le build come non testate; il gate mostra **Newest engine** in arancione, mai Certified. Il Setup continua a installare il pin, ma per il commit scelto mostra la scheda pronta in arancione con **Back to tested** e non la riporta indietro da solo. La scelta decade quando il checkout non è più su quel commit o quando un aggiornamento di NX Library porta un pin che lo contiene; se l'override l'aveva acceso la scelta, torna spento. Settings e Setup usano la stessa frase (`NewestEngineCopy.note`).
@@ -150,6 +150,8 @@ Se il motore termina correttamente con un bundle valido e fallisce solo l’inst
Prima della pubblicazione, l’installer registra una piccola ricevuta accanto all’app con percorso di staging e identità filesystem dei bundle. Una ricevuta per destinazione resta dopo il successo e viene aggiornata al prossimo porting: se il processo termina prima del salvataggio del catalogo, il tentativo successivo riconosce l’app già pubblicata e completa il record, anche dopo la pulizia dello staging. Evita così un secondo scambio che rimetterebbe la vecchia app al suo posto. Una destinazione modificata esternamente durante lo stesso tentativo causa un errore conservativo. La vecchia app e l’eventuale sorgente copiata vengono eliminate solo dopo la pubblicazione.
+La 0.1.4.2 (in attesa di validazione manuale) mostra il tempo trascorso della fase attiva e formatta durate ed ETA in ore oltre i 60 minuti. Un timer a 1 Hz sul main run loop aggiorna la fase; il controllo di `logs/pso_offline_mac.log` gira fuori dal main thread. `PipelineArchiveProgress` legge soltanto log modificati dopo l'inizio della fase e conta i gruppi dichiarati `ready`, distinguendo `compiled` e `reused`: i file temporanei del compilatore e le chiavi delle vecchie cache non sono una prova di lavoro completato. Le dimensioni dei gruppi vengono dal log, senza assumere quelle predefinite. La stima riguarda le pipeline, non il confezionamento successivo dell'app. Formati ignoti non inventano un avanzamento; cambi di fase, annullamento e fine build invalidano i controlli ancora in corso. `PortingFailureClassifier` riconosce anche lo spazio richiesto da `pack_app.py`; il fallback sulle chiavi si applica solo alle sorgenti che le richiedono.
+
`BuildRecord` conserva l’esito e la provenienza del build. `PortingFailureClassifier` riconosce i messaggi del motore e propone un’azione, presentata da `AppViewModel+Porting`. `PortingNotice` decide se notificare l’esito mentre l’app è in background. `NotificationService` gestisce la notifica di sistema e la destinazione del clic.
Gli adattamenti di b64a5ff0 riconoscono i nuovi errori di chiavi troppo vecchie, conservando la versione firmware quando conosciuta, e il ticket comune mancante nel pacchetto. Quest'ultimo usa il kind persistito `keys` con un dettaglio identificativo: anche un launcher precedente riesce a leggere la voce durante un rollback. La UI corrente offre la spiegazione sul ticket senza proporre di sostituire le chiavi o forzare il porting. L'evento `say` con cui l'engine sposta una vecchia cache shader nel nuovo file (`PortingProgress.shaderCacheWorkMessage`) accende `PortingProgress.isUpdatingShaderCache`; `PortingETA` mostra **Updating shader cache** senza avanzare la percentuale né stimare una durata. Lo stato si azzera a stage o step successivo e al termine del build. `PortingFailureEngineTests` verifica che l'engine del pin stampi ancora quel testo.
@@ -162,6 +164,8 @@ Il flusso Play controlla operazioni di salvataggio, recuperi e installazioni in
Off rimuove la preferenza cloud salvata dal launcher del gioco anche all’avvio. Gli override avanzati `DM_SAVE_SYNC` nei file o nell’ambiente mantengono la precedenza del motore: `SaveLocation.inspect` ne segnala l’origine e mantiene il blocco delle operazioni incompatibili con la sincronizzazione attiva.
+Nella 0.1.4.2 (in attesa di validazione manuale), Play durante la firma dell'icona apre il dialogo Selene **Preparing** con **Cancel**. `IconLaunchWait` lega la richiesta all'identità del gioco e del dialogo; la chiusura o sostituzione del dialogo revoca la richiesta. Solo l'ultimo aggiornamento icona riuscito può riprendere Play, che rilegge il gioco corrente e ripete tutti i controlli dei salvataggi e dell'app. Un errore non avvia il gioco. Cancel lascia terminare la firma, necessaria a mantenere il bundle valido. L'attesa usa `launchingGameId` e blocca anche l'aggiornamento del frontend.
+
`RunningGameMonitor` confronta i bundle ID della libreria con le applicazioni in esecuzione e offre Resume e Close Game. `LaunchReadinessProbe` attende una finestra visibile del processo per chiudere la schermata di lancio. `PlaySessionStore` registra avvio, heartbeat, fine e riconciliazione delle sessioni. `ActivitySummary` aggrega la settimana, mentre `LibraryPresentation` e `GameDetailPresentation` preparano i valori delle schermate.
## Cheats
@@ -214,7 +218,9 @@ LaunchBox e SteamGridDB non entrano nella griglia automatica: ciascuno ha un riq
`NintendoStoreMetadataService`, `GameTDBStore`, Title DB e le sorgenti di artwork raccolgono descrizioni, date, generi, copertine, loghi, sfondi e screenshot. `ArtworkNames` confronta i nomi per limitare i candidati al gioco o alle sue edizioni. `ArtworkPicker` e `MetadataOptions` aggregano i risultati per i selettori. `ArtworkPicks` e `MetadataPicks` conservano le scelte manuali.
-L'icona scelta in Change artwork (slot Icon, conservata a 1024 px) diventa anche l'icona dell'app del gioco in Finder e nel Dock (`AppIconWriter`, `AppViewModel+AppIcon`). Un'app senza icona propria (NUP Switch 2, dump) la riceve subito; un'app con l'icona del gioco la riceve solo se il giocatore lo sceglie nel pop-up dopo la scelta; Automatic rimette l'icona del gioco. La scrittura crea `Resources/AppIcon.icns`, marca `Info.plist` con `NXLibraryAppIcon`, rifirma il bundle ad hoc come l'engine e verifica con `codesign --verify --deep --strict`; l'icona originale resta in `Application Support/NXLibrary/app-icons//`, e ogni errore rimette i file toccati. Avviene dopo ogni build installata (la rebuild sostituisce l'app, e l'engine ricrea il pacchetto se trova file cambiati), all'avvio e quando un gioco si chiude, mai mentre il gioco gira o si costruisce; durante la firma (circa 50 secondi per un gioco di 9–12 GB) avvio e build del gioco attendono, e la riga di stato di Change artwork mostra l'avanzamento. Una scelta fatta mentre la stessa app si firma viene rieseguita alla fine, così vale l'ultima. Dopo ogni scansione della libreria `reconcileAppIcons` confronta lo stato voluto con l'app (marca `NXLibraryAppIcon`) e rimette in coda le differenze, per esempio una scelta fatta mentre l'app era stata spostata.
+La 0.1.4.2 prepara inoltre un resolver delle icone automatiche (`AutomaticIconResolver`, in attesa di validazione manuale e di chiarimento dell'accesso al provider secondario). Il gioco e le scelte manuali mantengono precedenza; `ArtworkStore.automaticIcon` prova Title DB e poi una corrispondenza esatta normalizzata in Switch Icon Showdown, senza confondere sequel, omonimi o edizioni. Un title ID Switch 2 da solo non identifica una Switch 2 Edition. La cache PNG resta separata da `ArtworkPicks`, con attribuzione e una sola piccola voce negativa per gioco (15 minuti). Il resolver condivide le richieste concorrenti della stessa istanza, limita dimensioni e tempi delle risposte, verifica host e immagini quadrate e consulta `robots.txt` prima del download secondario. **Al 9 novembre 2026, il sito esclude `/images/` dai crawler: il resolver non scarica le immagini di Switch Icon Showdown. Non considerare la issue #12 risolta o questa fonte operativa finché non viene chiarito un accesso consentito.** Le immagini automatiche disponibili possono riempire l'icona di un'app che non ne ha una, solo senza pick manuale e senza un esplicito Only here. Le app con icona propria rimangono intatte. Fallimenti di rete o ricerche ambigue non fanno fallire il porting. Nessun file del motore viene modificato.
+
+L'icona scelta in Change artwork (slot Icon, conservata a 1024 px) diventa anche l'icona dell'app del gioco in Finder e nel Dock (`AppIconWriter`, `AppViewModel+AppIcon`). Nella 0.1.4.2 (in attesa di validazione manuale) il pop-up offre **Only here** / **Use on the Mac** per ogni gioco, anche senza icona propria o non ancora costruito. `PortedGame.appIconOnMac` memorizza la scelta esplicita: false vieta la scrittura anche per NUP e dump; true la richiede quando esiste un'Icon pick; nil conserva la regola precedente (`usesPickedAppIcon` oppure assenza di icona originale) per le vecchie librerie fino alla prossima scelta. Riconciliazione e scrittura condividono `wantsPickedAppIcon`. Only here ripristina l'icona originale o la sua assenza se una personalizzata era già applicata. Automatic rimuove solo la scelta dell'immagine, conserva la destinazione esplicita (e l'opt-in legacy) e usa prima l'originale, anche dal backup durante una personalizzazione, poi l'icona estratta o quella online disponibile. La tile Icon location permette di modificare la destinazione anche senza pick. Se la ricerca non trova un sostituto, il writer ripristina l'originale o la sua assenza, senza lasciare la vecchia immagine personalizzata. La tile Automatic mostra l'anteprima risolta; lo stato distingue ricerca, firma e icona non disponibile. AppIconProgress conserva i testi condivisi fra UI e log. Dopo una build installata, inclusa un'installazione differita, l'esito della preparazione viene aggiunto al relativo log già chiuso dall'engine e al log della UI; assenza di artwork non cambia il successo della build. La riga informativa viene scritta solo dopo l'ultimo aggiornamento in coda, non per le successive modifiche manuali. Scansioni e rebuild conservano la preferenza. La scrittura crea `Resources/AppIcon.icns`, marca `Info.plist` con `NXLibraryAppIcon`, rifirma il bundle ad hoc come l'engine e verifica con `codesign --verify --deep --strict`; l'icona originale resta in `Application Support/NXLibrary/app-icons//`, e ogni errore rimette i file toccati. Avviene dopo ogni build installata (la rebuild sostituisce l'app, e l'engine ricrea il pacchetto se trova file cambiati), all'avvio e quando un gioco si chiude, mai mentre il gioco gira o si costruisce; durante la firma (circa 50 secondi per un gioco di 9–12 GB) avvio e build del gioco attendono, e la riga di stato di Change artwork mostra l'avanzamento. Una scelta fatta mentre la stessa app si firma viene rieseguita alla fine, così vale l'ultima. Dopo ogni scansione della libreria `reconcileAppIcons` confronta lo stato voluto con l'app (marca `NXLibraryAppIcon`) e rimette in coda le differenze, per esempio una scelta fatta mentre l'app era stata spostata.
`ArtworkStore` mantiene la cache immagini. `BoxArtService` risolve le copertine Switch di GameTDB. `GameArtLoader` e `SeleneHeroArt` alimentano sfondo e logo delle schermate con caricamento e cache dedicati.
diff --git a/docs/file-map.md b/docs/file-map.md
index 41e7602..7fbdd2b 100644
--- a/docs/file-map.md
+++ b/docs/file-map.md
@@ -180,6 +180,9 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT
| [Sources/NXLibraryCore/Models/TargetPlatform.swift](../Sources/NXLibraryCore/Models/TargetPlatform.swift) | Definisce il target mac-app e le sue etichette per l’interfaccia. |
| [Sources/NXLibraryCore/Models/GameRuntimeConfig.swift](../Sources/NXLibraryCore/Models/GameRuntimeConfig.swift) | Persiste opzioni per titolo, prepara Review e lingua del rebuild, mappa DM_* e unisce env.txt. |
| [Sources/NXLibraryCore/Models/LauncherSettings.swift](../Sources/NXLibraryCore/Models/LauncherSettings.swift) | Legge launcher.json e verifica i controlli supportati, con fallback per le build precedenti. |
+| [Sources/NXLibraryCore/Models/AppIconProgress.swift](../Sources/NXLibraryCore/Models/AppIconProgress.swift) | Stati e testi della preparazione icona, con esito informativo aggiunto al log della build conclusa. |
+| [Sources/NXLibraryCore/Models/IconLaunchWait.swift](../Sources/NXLibraryCore/Models/IconLaunchWait.swift) | Conserva una richiesta Play durante la firma dell'icona e la revoca su annullamento, errore o chiusura del dialogo. |
+| [Sources/NXLibraryCore/Services/PipelineArchiveProgress.swift](../Sources/NXLibraryCore/Services/PipelineArchiveProgress.swift) | Legge il log pipeline della build corrente e conta solo i gruppi confermati pronti dal motore. |
| [Sources/NXLibraryCore/Models/PortingProgress.swift](../Sources/NXLibraryCore/Models/PortingProgress.swift) | Aggrega eventi del motore in fasi e percentuale complessiva per la UI. |
| [Sources/NXLibraryCore/Models/PortingPresentation.swift](../Sources/NXLibraryCore/Models/PortingPresentation.swift) | Definisce fallimento persistito, ETA, aspetto della tile e pill del porting. |
| [Sources/NXLibraryCore/Models/PortingFailureReason.swift](../Sources/NXLibraryCore/Models/PortingFailureReason.swift) | Classifica messaggi del motore e rifiuti preliminari in motivazioni e azioni spiegabili. |
@@ -255,6 +258,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT
| File | Responsabilità |
|---|---|
| [Sources/NXLibraryCore/Models/OnlineBackgrounds.swift](../Sources/NXLibraryCore/Models/OnlineBackgrounds.swift) | Modella sorgenti e risultati e interpreta le risposte dei provider di sfondi fissi e video. |
+| [Sources/NXLibraryCore/Services/AutomaticIconResolver.swift](../Sources/NXLibraryCore/Services/AutomaticIconResolver.swift) | Risolve icone automatiche con cache, abbinamento prudente e rispetto delle restrizioni del provider. |
| [Sources/NXLibraryCore/Services/ArtworkStore.swift](../Sources/NXLibraryCore/Services/ArtworkStore.swift) | Scarica, ridimensiona e conserva immagini e screenshot nella cache per titolo e tipo. |
| [Sources/NXLibraryCore/Services/AppIconWriter.swift](../Sources/NXLibraryCore/Services/AppIconWriter.swift) | Scrive l’icona scelta nell’app del gioco, conserva l’originale, rifirma e verifica, e annulla tutto in caso di errore. |
| [Sources/NXLibraryCore/Services/ArtworkPicker.swift](../Sources/NXLibraryCore/Services/ArtworkPicker.swift) | Aggrega candidati per cover, sfondo, icona e logo e conserva le scelte manuali. |
@@ -456,6 +460,7 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT
| [Tests/NXLibraryCoreTests/AppUpdatePresentationTests.swift](../Tests/NXLibraryCoreTests/AppUpdatePresentationTests.swift) | Verifica che i controlli manuali non permettano sostituzioni di copie di sviluppo, in sola lettura o con una versione uguale/più recente. |
| [Tests/NXLibraryCoreTests/ArtworkPickerFocusTests.swift](../Tests/NXLibraryCoreTests/ArtworkPickerFocusTests.swift) | Spostamenti nella griglia dei candidati e passaggi fra slot di artwork. |
| [Tests/NXLibraryCoreTests/ArtworkPickerTests.swift](../Tests/NXLibraryCoreTests/ArtworkPickerTests.swift) | Decodifica Title DB, candidati per slot e scelte manuali. |
+| [Tests/NXLibraryCoreTests/AutomaticIconTests.swift](../Tests/NXLibraryCoreTests/AutomaticIconTests.swift) | Priorità delle fonti, ambiguità, edizioni, cache offline, immagini invalide e opt-out del sito. |
| [Tests/NXLibraryCoreTests/ArtworkStoreTests.swift](../Tests/NXLibraryCoreTests/ArtworkStoreTests.swift) | Cache, download, immagini trasparenti, ridimensionamento e fallback. |
| [Tests/NXLibraryCoreTests/BoxArtServiceTests.swift](../Tests/NXLibraryCoreTests/BoxArtServiceTests.swift) | Matching GameTDB, regioni e durata delle ricerche senza risultato. |
| [Tests/NXLibraryCoreTests/BuildCommandBuilderTests.swift](../Tests/NXLibraryCoreTests/BuildCommandBuilderTests.swift) | Contratto CLI, opzioni runtime/build e propagazione dell’override Metal. |
@@ -528,6 +533,10 @@ La pubblicazione comprende anche [CONTRIBUTING.md](../CONTRIBUTING.md), [SECURIT
| [Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift](../Tests/NXLibraryCoreTests/PortingFailureReasonTests.swift) | Classificazione dei fallimenti e corrispondenza con i messaggi del pin, anche da un checkout separato scelto con NX_TEST_ENGINE_REPO. |
| [Tests/NXLibraryCoreTests/PortingNoticeTests.swift](../Tests/NXLibraryCoreTests/PortingNoticeTests.swift) | Condizioni e testi delle notifiche di esito del porting. |
| [Tests/NXLibraryCoreTests/PortingPresentationTests.swift](../Tests/NXLibraryCoreTests/PortingPresentationTests.swift) | ETA, tile, pill e rappresentazione dei fallimenti del porting. |
+| [Tests/NXLibraryCoreTests/AppIconPreferenceTests.swift](../Tests/NXLibraryCoreTests/AppIconPreferenceTests.swift) | Ambito dell’icona: scelta esplicita, vecchie librerie, scansioni, persistenza e rebuild. |
+| [Tests/NXLibraryCoreTests/AppIconProgressTests.swift](../Tests/NXLibraryCoreTests/AppIconProgressTests.swift) | Verifica che l'esito di un'icona mancante non sovrascriva il log o segnali una build fallita. |
+| [Tests/NXLibraryCoreTests/IconLaunchWaitTests.swift](../Tests/NXLibraryCoreTests/IconLaunchWaitTests.swift) | Attesa Play: successo, errore, annullamento, gioco diverso e nuova firma in coda. |
+| [Tests/NXLibraryCoreTests/PipelineArchiveProgressTests.swift](../Tests/NXLibraryCoreTests/PipelineArchiveProgressTests.swift) | Log obsoleti, righe incomplete, cache riusata, gruppi paralleli e GPU diverse. |
| [Tests/NXLibraryCoreTests/PortingProgressTests.swift](../Tests/NXLibraryCoreTests/PortingProgressTests.swift) | Aggregazione delle fasi, avanzamento monotono e peso degli shader. |
| [Tests/NXLibraryCoreTests/ProdKeysLocationsTests.swift](../Tests/NXLibraryCoreTests/ProdKeysLocationsTests.swift) | Selezione e validazione dei possibili percorsi delle chiavi. |
| [Tests/NXLibraryCoreTests/RunningGamesTests.swift](../Tests/NXLibraryCoreTests/RunningGamesTests.swift) | Associazione bundle ID, applicazioni aperte e voci della libreria. |
diff --git a/docs/media/README.md b/docs/media/README.md
index 3eb09c8..382d4a5 100644
--- a/docs/media/README.md
+++ b/docs/media/README.md
@@ -16,6 +16,7 @@ Le otto catture fornite dall'utente il 2 novembre 2026 sono state rinominate nel
| `Color theme.png` | [color-themes.webp](color-themes.webp) | Artwork, themes and sound: scelta della palette. |
| `Background Static Wallpaper.png` | [static-wallpapers.webp](static-wallpapers.webp) | Artwork, themes and sound: sfondi statici. |
| `Background Wallpaper Live .png` | [live-wallpapers.webp](live-wallpapers.webp) | Artwork, themes and sound: sfondi animati. |
+| `Mods.png` (7 novembre 2026) | [mod-store.webp](mod-store.webp) | Mods: lo store con il banner Featured e le righe. |
Le copie WebP sono larghe 1920 pixel, mantengono proporzioni e contenuto delle catture e usano il profilo colore sRGB. Occupano complessivamente circa 2,3 MB, rispetto ai circa 73 MB degli originali. I metadati personali non vengono trasferiti. Le didascalie e i testi alternativi sono in inglese; ciascuna immagine ha una propria riga per preservare la leggibilità.
diff --git a/docs/media/mod-store.webp b/docs/media/mod-store.webp
new file mode 100644
index 0000000..974f9fc
Binary files /dev/null and b/docs/media/mod-store.webp differ
diff --git a/docs/reviews/2026-11-09-hotfix-0.1.4.2.md b/docs/reviews/2026-11-09-hotfix-0.1.4.2.md
new file mode 100644
index 0000000..2ce147a
--- /dev/null
+++ b/docs/reviews/2026-11-09-hotfix-0.1.4.2.md
@@ -0,0 +1,45 @@
+# 0.1.4.2 — release record, 2026-11-09
+
+The maintainer authorized committing, integrating and publishing 0.1.4.2, and explicitly requested adopting latest nxnative 79741184 rather than retaining bd979ced. The Icon / Automatic / Icon location flow was confirmed in Dev. Exact-hash game build/visible launch, build-progress cancellation and signing-wait stress cases not explicitly reported remain unconfirmed; release authorization is not represented as having performed them.
+
+## Final scope
+
+- Session card: `<1m` and a fixed leading-aligned content width prevent short-session text overflow (earlier commit 608e7e1).
+- Xcode requirements: parse the installed `Xcode 27.2` prefix correctly and avoid asking to upgrade an already-current toolchain.
+- Disk errors: recognize pack_app's space check including its 1 GiB reserve; NUP/extracted sources do not receive the missing-keys fallback. Retain @eduardomozart's PR #13 fix/tests and extend the engine-message check to dm/mac.
+- Long builds: elapsed time updates, hour formatting and percentages from @eduardomozart's PR #11. Pipeline tracking was replaced with a current-log reader: actual batch sizes, confirmed ready (compiled/reused), no inference from temporary/cache files, joined parallel-worker records, generation guards after cancellation/next stage, and an estimate limited to pipelines.
+- Icon source and location are independent. Automatic preserves Only here / Use on the Mac and original-image provenance. Icon location is reachable through the existing keyboard/controller grid. Missing replacements remove the prior personal app icon and restore the original/generic icon. The picker and finished build log explain a missing image; Play remains available.
+- Play waits safely for icon signing; Cancel or a replaced notice revokes the pending launch. Rapid changes finish one signature then reread the latest source/location.
+- Title DB fallback validates/caches square images separately from personal picks. Switch Icon Showdown remains blocked by its current /images/ opt-out; no claim that issue #12's full automatic Switch 2 fallback is solved. LaunchBox remains a manual artwork source.
+- A regression check preserves a saved 120 FPS selection through unrelated edits and launch migration. Six upstream profiles currently offer 120; Ocarina offers 30/60. Metadata support is not a physical 120 FPS gameplay certification.
+- README highlights and mod-store screenshot/documentation (earlier commit 2fcb114).
+- Pin latest upstream 79741184 on the maintainer's explicit instruction. See the [71-commit review](../upstream-upgrades/2026-11-09-bd979ced-79741184-review.md): same Metal 32023.922, no new Python packages, changed shader chain, backend/runtime improvements and existing metadata-driven controls. Installed games retain their embedded engine until rebuilt.
+
+## Verification evidence
+
+- Full Swift suite: **1,166 tests, zero failures** before final release packaging; scripts/release.sh repeats it against the exact committed release tree.
+- Publication/runtime checks and **17 Python tests** passed.
+- Dev 0.1.4.2 compilation and strict/deep signature verification passed. Release script validates the release marker/version, updater ZIP extraction, extracted signature, and DMG creation.
+- Real Ocarina offline-pipeline log replay: **34,815 / 34,815** confirmed pipelines, including the joined-worker-record case. This verifies parsing and is not a new interactive build.
+- Existing Ocarina build inspected in both catalog and installed DMCommit: **7ffa63cc**, completed successfully on 2026-11-09 with Metal 32023.922 / Xcode 27.2. It already contains 66 of the 71 reviewed engine commits and the same shader chain. Do not ask for another large Ocarina rebuild just to see those already-present changes. A smaller game can supply exact-new-pin manual validation later.
+- Icon rollback tests use synthetic bundles, distinct A/B image outputs and original/absent-original restoration. Missing-icon log append preserves engine output. No game assets or real keys are used by tests.
+- No computer-use/UI automation was performed. No cache or released installed app was replaced by the agent's verification.
+
+## Remaining human checks
+
+- A build and visible launch on exact 79741184, optionally a smaller game. Observe pipeline progress, end-of-build icon log and destination persistence.
+- Build cancel/restart without stale progress; Play during signing and Cancel without a delayed launch; rapid icon changes, running-game deferral and persistence where not already confirmed.
+- Actual 120 FPS gameplay and Switch 2 controller pairing/motion/rumble with appropriate hardware, if used.
+- Installing the published update through the user's released copy remains a maintainer check; archive/signature validation does not claim that the interactive update ran.
+
+## Community follow-up
+
+Public replies were posted to issues [#3](https://github.com/Bium23/NxLibrary/issues/3#issuecomment-6738355696), [#9](https://github.com/Bium23/NxLibrary/issues/9#issuecomment-6738356069), [#10](https://github.com/Bium23/NxLibrary/issues/10#issuecomment-6738356404) and [#12](https://github.com/Bium23/NxLibrary/issues/12#issuecomment-6738356680). Grid, Homebrew, localization and the full secondary-provider icon fallback remain open proposals; do not close them because this release ships adjacent fixes.
+
+PR [#13](https://github.com/Bium23/NxLibrary/pull/13#issuecomment-6738357603) is retained with attribution; PR [#11](https://github.com/Bium23/NxLibrary/pull/11#issuecomment-6738357113) is adapted with its pipeline implementation replaced. Once the hotfix is on main, link its integration commit/release and close those contribution PRs as incorporated, without falsely marking the originals merged. Keep author/co-author credit to Eduardo Mozart de Oliveira (@eduardomozart).
+
+PR [#1](https://github.com/Bium23/NxLibrary/pull/1#issuecomment-6738358023) mixes an obsolete settings implementation with unverified performance changes and conflicts with current main. Current saved-settings builds already preserve 120; older custom builds without DMSettings need rebuilding. A focused measured performance PR was requested. It remains open rather than closing an unverified optimization as solved.
+
+## Packaging
+
+VERSION and release notes name 0.1.4.2. Publish only through scripts/release.sh from the pushed main tree, using the reviewed notes in docs/private/release-0.1.4.2/notes.md. The script writes exact engine/Xcode requirement metadata for the installed app's updater. Keep former release artifacts outside work/release before its regeneration. Final remote release/asset/PR results are recorded in the task's handoff after publication, rather than inventing them in advance.
diff --git a/docs/upstream-upgrades/2026-11-09-bd979ced-79741184-review.md b/docs/upstream-upgrades/2026-11-09-bd979ced-79741184-review.md
new file mode 100644
index 0000000..820aeab
--- /dev/null
+++ b/docs/upstream-upgrades/2026-11-09-bd979ced-79741184-review.md
@@ -0,0 +1,153 @@
+# 2026-11-09 — nxnative bd979ced → 79741184 review
+
+**Status: adopted for 0.1.4.2 at the maintainer's explicit direction on 2026-11-09.** Previous pin: `bd979ced3ae6bad60bf86429a71d172297d93471`. New pin: `79741184af1558fa1ada0f61741eeecbd615b3f6` (upstream main fetched again immediately before adoption), **71 commits, 254 files**. The maintainer explicitly requested the latest engine for the release instead of retaining the old pin pending another game build. This overrides that adoption order for this release only; **no real build or visible launch on the exact new commit is claimed**. Automated tests, compatibility gate and release-archive verification remain required. Later upstream commits are outside this review.
+
+## Why adopt this engine
+
+Ocarina of Time benefits across build, startup and play: fewer first-draw stalls (task/mesh pipelines prewarmed and archived), archived-pipeline lookups indexed, compaction limited by tier instead of repeatedly rebuilding the whole archive, large bundled archives mapped instead of copied into RAM, render-target heaps reused as resolution changes, fixes for mist/depth/light-probe artifacts, and safer mesh dispatches above 1,024 workgroups. Frame-step changes target slow motion under load, separately from FPS. Shader arithmetic and HMMA lowering improve translation/GPU work.
+
+Upstream measurements are evidence for investigating, **not measurements or promises for this Mac**. Examples: one HMMA rewrite phase fell from 932 CPU seconds to 3.5 with identical output (1b9775a0; not total build duration); one external-drive Ocarina footprint fell from 19.0 to 12.2 GB (a1e155a3); distinct repeated-start scenarios fell from 762 seconds to 3.3 (4a815619) or 713 seconds to 23.6 and later 2.4 (4c3a03af). Those scenarios, devices, caches and routes differ.
+
+Other upstream results include The Rogue Prince of Persia on Mac (60/up to 120 FPS), OCTOPATH TRAVELER at 60, Minecraft Dungeons II at 60/up to 120, Hyrule Warriors: Age of Calamity v458752 at 60, and fixes affecting Luigi's Mansion 3, Super Mario Odyssey, Sparking! ZERO, Red Dead Redemption, LEGO Star Wars: The Skywalker Saga, Fire Emblem Fortune's Weave, Aokana, Minecraft, and multi-program Metal Gear Solid DLC. They require rebuilt game apps and are not all tested by the maintainer.
+
+## Contract and integration decisions
+
+| Capability / change | Decision in NX Library | Reason / practical effect |
+|---|---|---|
+| Shader/runtime fixes and DMSH_CHAIN `3ee52af3ca18` → `dedc9391c30b` | Backend | Upstream owns translation and cache invalidation. Keep the complete game build cache; next build re-splits/compiles the required shader data automatically. No manual cache deletion. Installed apps retain their old host until rebuilt. |
+| Pipeline archive indexes, compaction, task/mesh prewarm, memory mapping | Backend | New game host manages these. Offline `pso_offline` tools/log producer are unchanged in this delta, so the hotfix's confirmed-batch progress reader remains applicable. Startup prewarming is not the offline pipeline stage and is not included in that ETA. |
+| Dynamic resolution now supports Ocarina | Existing UI | Engine capability-gated `launcher.json` supplies the row and updated copy. No extra global switch. Off keeps full resolution; on lets the game reduce resolution under load. |
+| New 60/120 FPS profiles | Existing UI | Existing generic choice rows read actual options from each rebuilt app's launcher.json; no promise that a listed 120 FPS mode will be sustained on this Mac. |
+| `DM_SW2`, Joy-Con 2 / Pro Controller 2 BLE | Existing Game options UI + backend | Rebuilt apps advertise the toggle in Controls; off by default. Upstream pack_app writes NSBluetoothAlwaysUsageDescription. Pairing, rumble, motion, dual/single Joy-Cons and the mouse sensor are implemented in the game host. New C callbacks apply to an embedded host frontend, which NX Library is not; no duplicate Bluetooth transport in the library UI. This does not add BLE controller navigation to NX Library itself. Physical devices still need manual validation. |
+| Cloud send race protection | Backend | Upstream no longer overwrites a cloud save it could not compare at start or one changed afterward. Existing newer-save-wins behavior and backups remain when DM_SAVE_SYNC_CHOICE is unset. |
+| `DM_SAVE_SYNC_CHOICE` / DMSaveSyncChoice | New conflict-selection UI not adopted in this hotfix | NX Library currently chooses cloud location and protects import/restore, but does not compare both cloud container headers/hashes or ask which conflicting save wins. Passing ask without implementing that complete workflow would silently prevent synchronization. Leave unset, retaining upstream's existing behavior and added race protection; no new conflict dialog is claimed. |
+| `--icon` / ICON | Existing reversible artwork writer retained; direct build override not adopted for this candidate | The new engine accepts a JPEG/PNG before its control icon. Blindly passing a personal/online image would make it the packed app's apparent original and break Automatic/Only here restoration. Current post-install writer preserves the true original or its absence, supports queued choices and signing rollback. A direct-pack optimization needs explicit original provenance through new/held builds; it is not required to build with this engine and no avoided-signing performance claim is made. |
+| `--shader-files`, iOS pulling libraries, M1 iOS CPU target, AFC push | Not adopted: outside macOS app scope | --shader-files is explicitly refused with --platform mac-app. NX Library does not package or push iPhone IPAs; no libimobiledevice dependency added. |
+| Brackets in folder names; multi-program DLC indices | Backend | Upstream fixes glob/file discovery and DLC application-ID indexing. Source symlinks/workspaces remain unchanged. |
+| New diagnostics / internal defaults | No main UI controls | DM_JCMS_LOG, DM_NVN_BC_ALIAS, DM_SW2_VERBOSE/gyro mapping, DM_YIELD_CLOCK are diagnostics. Runtime render-target reuse stays upstream's default. |
+
+## Limits and tradeoffs to keep visible
+
+- The slow-motion frame-step fix is reasoned from the affected games and code, but the upstream note explicitly leaves a before/after run on a sufficiently slow Mac open. Validate actual game speed, not only the FPS counter.
+- OCTOPATH TRAVELER's upstream 60 FPS route has more CPU-side long frames than the 30 FPS route; the new cap is not a claim that stutter is resolved. Unity 120 FPS cases also depend on display/vsync behavior.
+- BLE controller validation differs by device: Joy-Con 2 motion/pairing/dual/single have upstream physical checks; Pro Controller 2 gyro scale/axes are borrowed and its rumble was not physically certified in the referenced note. Later mouse-sensor support supersedes playing.md's earlier “not yet” statement. No local physical controller test has been performed here.
+- Upstream states that a newly signed game build asks for Bluetooth access again. Our icon writer also re-signs the bundle; a repeated Bluetooth permission prompt after an icon change is therefore a possible interaction to verify, not a separately verified macOS guarantee.
+- A chain change means a longer next rebuild even with the existing cache. Extraction/workspace data are retained; upstream may replace/prune obsolete generated chain libraries as part of its own normal migration. No manual game-cache deletion was performed.
+
+## Reviewed sources
+
+- Complete commit inventory, implementation-facing commit notes and all Front-ends notes in the fixed range; changed source contracts in scripts/build_ipa_from_nsp.py, dm/mac/pack_app.py, docs/frontends.md.
+- Candidate script `-h` executed with Dev's bundled Python from a temporary read-only source snapshot. New arguments: --icon and --shader-files/--no-shader-files; mac-app/progress JSON/work-dir/title selection remain.
+- docs/env_switches.tsv and settings.tsv delta, changed game README notes, changed playing/settings and reference docs (frame rate, memory, movies, NVN objects, packing, rendering, rewriter, services, shaders, threads). Game code/data tables are upstream-only.
+- Python README install line and .github/requirements/tools.txt have no new dependency; no runtime.lock/NOTICE package update is needed. Diagnostic-only NumPy/Pillow/texture2ddecoder remain intentionally unbundled as documented by EngineRuntimeRequirementsTests.
+- dmsh_chain.lock still certifies Metal 32023.922 for macOS and iOS. minimumXcode remains 27.2; the existing 27B47 asset is unchanged. Translator DLL fingerprints change, not the Metal asset.
+- `game` event's icon may now be --icon rather than an embedded original; NX Library currently ignores this event and does not misclassify it as provenance. Other event contracts are unchanged.
+
+## Verification and adoption record
+
+- Full frontend suite with the candidate override passed **1,166 tests**, including PortingFailureEngineTests and EngineRuntimeRequirementsTests. The release script re-runs the suite against the now-updated source pin.
+- Compatibility gate passed for exact HEAD 79741184, clean engine tree, Metal 32023.922, Python 3.14 and required CLI options. The same toolchain remains in use.
+- Dev 0.1.4.2 was built and strict/deep signature verification passed during candidate evaluation. Final release packaging is validated by scripts/release.sh, which checks its ZIP with the same validator used by app updates.
+- Actual candidate-generated launcher metadata decoded through NX Library: 18 Ocarina settings, including the off-by-default Switch 2 controller toggle and capability-gated dynamic-resolution control. Six 120 FPS profiles were generated and checked. Synthetic metadata is not a game/hardware test.
+- Initial evaluation selected the candidate through NewestEngineSwitcher. The maintainer subsequently explicitly requested adopting the latest commit and publishing 0.1.4.2 without waiting for another game rebuild. The source pin is now 79741184. Existing cached upstream status is rejected when its base pin differs; a Newest choice already included in the pin is retired by the existing keeps rule. No automatic following of main was introduced.
+- A real build and visible game launch on exact 79741184 remain **unconfirmed**. The previous installed Ocarina build is evidence for 7ffa63cc only. This release does not claim local before/after performance or physical controller verification.
+
+## Existing Ocarina build discovered before asking for another rebuild
+
+The maintainer pointed out that Ocarina had already been rebuilt near main. Inspection confirms **7ffa63cca09f718d6c9deb830656291b608e4ddb** in both the library build record and the installed app's DMCommit (`7ffa63cca09f`). Its successful build ended at 2026-11-09 09:08:32 UTC (Metal 32023.922, Xcode 27.2, recorded as Newest/untested). No rebuild is needed merely to see the major startup/memory/shader improvements listed above: this build already includes the first 66 of the 71 reviewed commits.
+
+Only five commits follow that installed engine: samples-passed counter correctness (cfbdd93c), bracketed-folder discovery (cb4a97a6), multi-program DLC indexing (d9cd9de7), Core Haptics exception recovery (7238bfd1), and H.264 Annex B packet framing (79741184). **No shader chain or Metal lock change exists in this five-commit range.** The counter and haptics fixes can affect shared runtime paths, so they are not dismissed as irrelevant to Ocarina; however, they do not justify automatically asking the maintainer to repeat the whole expensive build for the already-present improvements. A real build and visible launch on 79741184 are still required before reporting that exact hash as manually game-validated; a smaller suitable game can serve that check. Existing 7ffa63cc gameplay cannot be reported as 79741184 gameplay.
+
+## 120 FPS integration check
+
+Six current profiles explicitly declare fps_max 120: DRAGON BALL: Sparking! ZERO (010035F022078000), Mario & Luigi: Brothership (01006D0017F7A000), The Rogue Prince of Persia (01008D9022462000), Minecraft Dungeons II (0100A7C01B792000), Pikmin 4 (0100B7C00933A000), Fire Emblem Fortune's Weave (04007EF01F8AE000). Running the actual candidate settings generator for all six confirms **30 / 60 / 120** choices; Ocarina's generator and its installed launcher.json both offer **30 / 60**.
+
+NX Library's generic Frame rate row already displays the build's choices and writes the exact string to saved settings. Before launching a modern DMSettings app, it migrates those choices and passes no legacy 30/60 FPS environment override. A focused regression check verifies that a saved 120 choice survives unrelated option edits and the same migration called before launch. No new 120 FPS control or global override is needed. Per-game versions/hardware/refresh-rate limits still follow upstream's profile notes; actual 120 FPS gameplay and visual selection on the maintainer's hardware are not newly certified by these metadata checks.
+
+## Focused maintainer check
+
+1. After updating/reopening, verify Settings → Engine uses pin 7974118 and the expected Metal compiler. A Newest choice at this same commit is retired; an override the player enabled independently remains their explicit choice.
+2. Do not repeat Ocarina just for changes already present in its 7ffa63cc build. To complete manual validation of 79741184, build one suitable real game with the existing cache (a smaller title is acceptable); observe pipeline progress, finish, saved log and icon location.
+3. Launch the newly built game to a visible screen. For Ocarina-specific evaluation, use the existing 7ffa63cc app or a later deliberately selected rebuild, load the usual save and enter Kokiri Forest. Check lighting/fog, foliage, terrain and movement speed. Keep save/route/settings comparable; fixed full resolution versus Dynamic resolution are separate comparisons.
+4. Quit and launch again twice: assess startup and first-entry stutters. The initial shader/build and a cold first prewarm should not be confused with subsequent starts. Record elapsed startup/memory only if comparable, without promising upstream's figures.
+5. Optional with hardware: Game options → Controls → Switch 2 controllers; enable, pair by Sync while the game runs, check controls/Home/rumble. macOS asks Bluetooth per newly signed game build; the game host handles this, not NX Library.
+
+After a future real build/launch: record the actual build/toolchain/log and human-visible launch; do not retroactively claim that adopting the pin performed those checks. Pin, README, architecture and changelog are updated for the maintainer-directed release. Rollback uses Back to tested; installed game apps keep their engine until rebuilt. A rollback changes the shader chain again.
+
+## Every commit classified (oldest first)
+
+| Commit | Area | Upstream change |
+|---|---|---|
+| [4f47d211](https://github.com/seedds/nxnative/commit/4f47d211) | Host / runtime | ROMANCE OF THE THREE KINGDOMS XIV: Diplomacy and Strategy Expansion Pack plays on the Mac (1.0.10 with its DLC, from the provisional profile unchanged); the deferred-release drain no longer reads entries it has handed back to the queue: with g_smu busy it sorted its list again after returning the retired resources, and another drain could free them in between (a crash in class_conformsToProtocol from a completion handler, the game's main menu) |
+| [51ecd693](https://github.com/seedds/nxnative/commit/51ecd693) | Shader / pipeline | Colour targets are write-masked per component by the pixel shader's output map (SPH word 18, now in the reflection's raster facts): the translator stores only the mapped components and Metal wrote the rest of the attachment undefined; a target's load is skipped only when the map covers every channel its format stores (chain 094f939bfef9; reported by Lichii on Ocarina of Time's depth pass, not reproduced here) |
+| [ea0c98df](https://github.com/seedds/nxnative/commit/ea0c98df) | Host / runtime | Honor NVN queue dependencies and preserve conditional state across continuations |
+| [7d8dcdf8](https://github.com/seedds/nxnative/commit/7d8dcdf8) | Game profiles / frame rate | Ocarina of Time and Mario Kart World no longer play in slow motion when frames run long: their frame step is the host's (fps_target 60), from stubs placed by the function they patch on any build that keeps it (MATCH_FRAME_STEP, nso_match.locate_stub), a game binding where frame-step stubs were build-bound; Ocarina's frame ratio is the host's step x its base rate / 60, Mario Kart World's interval the step (at most 2) unless its own 30 fps mode is set (both places named by Lichii, facts only) |
+| [e940e1f3](https://github.com/seedds/nxnative/commit/e940e1f3) | Game profiles / frame rate | The Rogue Prince of Persia plays on the Mac (1.1.0, Unity 6000.0.64f1 IL2CPP, from the provisional profile unchanged in its keys): the title, the first-run options, the opening narration and movie, the first level with running, jumping, climbing, the weapon and the wall run; 30 fps as the game asks, sound in the game's mix, no host change |
+| [eed7c429](https://github.com/seedds/nxnative/commit/eed7c429) | Shader / pipeline | A pipeline archive flush period is handed over at its 2,000th capture, during a compacting prewarm too, and a pipeline the capture refuses is built by the plain compiler: one period of 8,693 bases filled Metal's archiver store (MDB_MAP_FULL, a 10 MB LMDB map per serializer) and Ocarina of Time's Kakariko Village lost 1,502 pipelines at 30-70 ms a frame |
+| [555122a9](https://github.com/seedds/nxnative/commit/555122a9) | Shader / pipeline | Reads through a uniform buffer's handle table are no longer marked as texture pool reads: the mark went through the bitmap address in the table's header, which htab_gpu leaves 0, and Fire Emblem Fortune's Weave's lighting pass at Alcineus Bridge took 196.7 ms a frame on an M1 Pro (4 fps); a table's index is its element, not a texture ID, and the host has made every texture the table names (chain c096b1d1dc53; found by HammerChain in PR #1738) |
+| [a00f03bb](https://github.com/seedds/nxnative/commit/a00f03bb) | Input / display / audio | Mac: one Pro Controller is one pad: IOHIDManager hands a device over once per matching dictionary it meets, so the HID reader knows a pad by its I/O Registry entry, not by its IOHIDDeviceRef (PR #1722) |
+| [62f6a52c](https://github.com/seedds/nxnative/commit/62f6a52c) | Game profiles / frame rate | The Rogue Prince of Persia plays at 60 fps and up to 120 on the Mac: Unity's QualitySettings.vSyncCount and Application.targetFrameRate are found through the engine's internal-call table in any Unity build that keeps it (unity_fps.py; Unity 6000.0 and 2021.3 read), and the host writes them; the display's vsync event runs at 120 Hz while 120 fps holds, as Unity's NVN device waits for each vblank (Sparking! ZERO and Pikmin 4 unchanged at 120) |
+| [1b9775a0](https://github.com/seedds/nxnative/commit/1b9775a0) | Shader / pipeline | The HMMA rewrite finds each function's opening and end once: Ocarina of Time's 71 HMMA shaders took 932 s of CPU in hmma_msl, now 3.5 s, with byte-identical output (chain 579d06bf3932; PR #1711) |
+| [1794a35a](https://github.com/seedds/nxnative/commit/1794a35a) | Docs / tests | Fix patch registry test namespace types and shrink the lint baseline |
+| [651369bc](https://github.com/seedds/nxnative/commit/651369bc) | Build / extraction / CLI | An IPA can carry the shader library as its separate files, for install tools that cannot copy a large file: --shader-files in the NSP script (SHADER_FILES, DM_SHADER_FILES=1 for build_ipa.sh, pack_ipa.py --shader-files), off by default. AltServer copies each file to the device with one afc_file_write, which libimobiledevice sends with one send() returning an int, so Tears of the Kingdom 1.4.3's 2.10 GiB b-.img failed to install (#1688); the host reads a library folder before an image (PR #1697) |
+| [04419bc2](https://github.com/seedds/nxnative/commit/04419bc2) | Shader / pipeline | Ocarina of Time runs at full resolution, and its dynamic resolution no longer sits at its floor: 128-bit raw global loads are one vector load (GPU 16.3 -> 12.6 ms on an M4 Pro), occlusion conditions are read once landed (-0.6 ms), a frame-start timestamp from a slot its encoder never wrote is dropped, and a wait on a queued WRITE event signal is released at encode (chain 89f44f9b6bb0) |
+| [c78d7375](https://github.com/seedds/nxnative/commit/c78d7375) | Docs / tests | Fix loop capture in frame-step registry test |
+| [b62b1c54](https://github.com/seedds/nxnative/commit/b62b1c54) | Cloud saves | Cloud saves: a send never replaces a cloud save the start did not compare, and a front end can ask the player when both changed: DM_SAVE_SYNC_CHOICE (ask, cloud:, local:, local:none, offline); without it the newer save still wins (PR #1785) |
+| [3cbe7a6f](https://github.com/seedds/nxnative/commit/3cbe7a6f) | Docs / tests | README: questions and results go to GitHub Discussions, with Ocarina of Time's performance followed in discussion #1789; the acknowledgements move to the footer |
+| [ab20f4af](https://github.com/seedds/nxnative/commit/ab20f4af) | Game profiles / frame rate | Minecraft Dungeons II plays at 60 fps, up to 120 on the Mac: its config sets rhi.SyncInterval 2 and t.MaxFPS 0, and its bUseFixedFrameRate is off, so the present interval the host writes (found by name) is all it needs; the same walk at 30, 60 and 120 ends at the same spot (frame time p50 33.4 / 16.7 / 8.3 ms on an M4 Pro), and the user judged the speed right at 120. Its UE 5 FixedFrameRate reflection entry has a layout ue_fixed_rate.py does not read (open; no write needed here) |
+| [028a2631](https://github.com/seedds/nxnative/commit/028a2631) | Input / display / audio | Luigi's Mansion 3 holds 60 fps again: in display mode nvnWindowGetPresentInterval answers the interval the window presents at, so the game no longer paces itself for its own interval 2 now that vi::ListFrameHistory lists the presents |
+| [8bdfcb73](https://github.com/seedds/nxnative/commit/8bdfcb73) | Host / runtime | Clock waits sleep to their predicted end: a yield loop that reads the clock backs off from its start and, at a steady period, sleeps on time to just before its next end; Three Houses' frame-limiter cut is removed (issue #635) |
+| [877c7eca](https://github.com/seedds/nxnative/commit/877c7eca) | Shader / pipeline | Ocarina of Time's mist no longer flickers with dark specks: a pixel shader comparing gl_FragCoord.z with a depth it read takes that depth as its own z when it lies in the gap Apple's GPU leaves between the depth it stores and the fragment's [[position]].z (chain a02f1e0dfdde) |
+| [8e4a993b](https://github.com/seedds/nxnative/commit/8e4a993b) | Shader / pipeline | gl_FragCoord.z against the depth stored for its own surface: an M6 stores exactly the fragment's z (check_t_zsnap: 0 of 718,400 pixels, against 9,511 on an M4 Pro), so the mist specks were the M4 generation's; M1-M3 and iPhones not measured |
+| [eddae74f](https://github.com/seedds/nxnative/commit/eddae74f) | Host / runtime | Event descriptors are numbered as the bsd service numbers them, the lowest free one from 0, and Select is Poll over its sets: Minecraft's NetherNet put its descriptor (0x4000) in a 1024-bit fd_set on the stack and crashed 2 KB past it (PR #1651) |
+| [37f076a5](https://github.com/seedds/nxnative/commit/37f076a5) | Shader / pipeline | Extended products leave spirv-cross's optnone helper: scalar uint and int calls of spvMulExtended reach inline overloads that take the halves of the 64-bit product, so the compiler folds Switch 2's constant divisions (Ocarina of Time: 348,587 calls, 98% by a power of two); on an M4 Pro the Kokiri Forest deck route ran at 59.1 fps instead of 54.0, GPU 15.55 -> 14.76 ms, and the title movie's GPU 12.96 -> 12.44 ms (chain 39417042cfe8; PR #1741) |
+| [8fd4b724](https://github.com/seedds/nxnative/commit/8fd4b724) | Game profiles / frame rate | OCTOPATH TRAVELER plays at 60 fps: the rewriter finds UE 4.18's console variables registered by inlined static constructors, which address each object from a page base made far before the name (str x8, [x22, #k] the vtable, then str x0, [x22, #k + 0x10] the value pointer); the base is taken back to its adrp + add across forward branches over else blocks, and nothing is found if another write or a branch from outside lands between (issue #635) |
+| [7b6506dd](https://github.com/seedds/nxnative/commit/7b6506dd) | Host / runtime | A dispatch fills its compute bindings before it opens an encoder: a binding that upgrades its texture for a view of another format copies the pixels through the copy encoder, which opened a second encoder beside DM_NVN_DISPATCH_OWN's (Ocarina of Time crashed in AGX prepareForEnqueue at the Kokiri Forest load) and by default landed after the dispatch's barrier, so the shader could read the old pixels (PR #1755) |
+| [9b69d27d](https://github.com/seedds/nxnative/commit/9b69d27d) | Host / runtime | A WaitSync on a sync no fence had reached when it was submitted passes: Sparking! ZERO's single queue parked for good at its first load after the title, and its RHI interrupt thread's SyncWait stalled the game |
+| [3b6b9803](https://github.com/seedds/nxnative/commit/3b6b9803) | Docs / tests | t_present_crop presents through a real queue again: since ea0c98df a QueuePresent goes through the queue scheduler, and the fixture's null queue crashed it (SIGSEGV at nvn_queue.inc:207); the fixture now holds replay with nvn_queue_test_hold while the window or layer crop changes, checks that the held replay queues nothing, waits for the frame's display ticket, and runs with present snapshots and the encode thread each on and off (PR #1757) |
+| [50aefc12](https://github.com/seedds/nxnative/commit/50aefc12) | Game profiles / frame rate | Fire Emblem Engage and Pokémon Brilliant Diamond lose their build-bound patches: their SDK rows give way to the SDK-wide rules (node area and 2 MB alignment), Engage's vSyncCount cut to the host's write of the level's count (fps_target 60), and Brilliant Diamond's fixed targetFrameRate row to unity_fps.py, which now reads Unity 2019.4 / 2017.4's internal calls registered from code and their framed manager getters (issue #635) |
+| [7de11da5](https://github.com/seedds/nxnative/commit/7de11da5) | Game profiles / frame rate | Ocarina of Time's dynamic resolution climbs to full size: with DM_DYNAMIC_RESOLUTION=1 a stub in place of the game's level function climbs on the load measured at each level, instead of the game's extrapolation by pixel count that kept it at 683x384 for good |
+| [7f502220](https://github.com/seedds/nxnative/commit/7f502220) | Shader / pipeline | Render targets in a GPU-only pool are placed in heaps of their own, and a freed target's heap waits resident for the next target it fits: Ocarina of Time's dynamic resolution, free to move since 04419bc2, made about 3 new Metal textures a frame in its 305 MB target pool; on an M6 (16 GB), alternating the switch every 300 frames while the resolution moved, the target site added 455-521 MB to the residency set per 300 frames off and 27-37 MB on, frame p90 33-50 ms against 17-18 ms (issue #1831) |
+| [35b6b78c](https://github.com/seedds/nxnative/commit/35b6b78c) | Host / runtime | A sampler keeps its state from byte 8 on: Super Mario Odyssey's sampler cache (main+0x980e20, 1.4.1) keys its entries by a CRC-16 (0xA001) of bytes 8 to 0x5f of the NVNsampler alone, and the filter and wrap modes at bytes 4 to 7 made samplers that differ only in them one entry; from the regress route's save it registered 168 states 3,368 to 5,737 times instead of 168 (151,626 of 276,480 initializations met a CRC another state had, none now). nvnSamplerInitialize writes every byte of the record; t_nvn.c checks equal states give equal bytes and filter or wrap alone a different CRC (first reported by the S2Arm project) |
+| [6059cbab](https://github.com/seedds/nxnative/commit/6059cbab) | Game profiles / frame rate | The last four SDK rows give way to the SDK-wide rules: Ace Attorney Investigations Collection's (311deab1), Brain Memory Training Academy's (12f16103) and Patrick's Parabox's (9c73fb73) builds now take the 2 MB alignment beside the node area cut, and Hollow Knight: Silksong's (e01950c5) rewrites byte-identical; only SDK 4.4.0's row is left (issue #635) |
+| [d8b2b64a](https://github.com/seedds/nxnative/commit/d8b2b64a) | Docs / tests | Fix CI lint and fixture failures after the queue and shader updates |
+| [2c2c5491](https://github.com/seedds/nxnative/commit/2c2c5491) | Game profiles / frame rate | Engine-wide rewriter rules replace three games' build cuts: Unity 2019's ALLOC_BUCKET block limit found by the allocator's name (unity_bucket.py; Subnautica and Below Zero), and Unity 2018's ThreadedStreamBuffer load-acquires matched by function in any main (engine_patches.py, loaded like sdk_patches.py; FINAL FANTASY CRYSTAL CHRONICLES 1.0.0 and 1.0.3, Raging Loop) (issue #635) |
+| [56e4f2ab](https://github.com/seedds/nxnative/commit/56e4f2ab) | Game profiles / frame rate | Remove the three patches.py files the engine-wide rules of 2c2c5491 replaced (Subnautica, Subnautica: Below Zero, FINAL FANTASY CRYSTAL CHRONICLES Remastered Edition), left out of that commit (issue #635) |
+| [bf420fda](https://github.com/seedds/nxnative/commit/bf420fda) | Shader / pipeline | Native task / mesh pipelines are recorded, prewarmed and kept in the pipeline archive: Ocarina of Time's foliage, shadows and enemies no longer stall at their first draw (59.5 s of builds at the Kokiri Forest entry on an M5 Max with a cold Metal cache, one frame of 42.9 s), and its profile seeds 123 of them (PR #1368) |
+| [e3ad8255](https://github.com/seedds/nxnative/commit/e3ad8255) | Host / runtime | Every exclusive access gets ordering: the rewriter sets bit 15 of each ldxr* / ldxp and stxr* / stxp (acquire on the load, release on the store), by encoding in every module of every game, instead of acquire for one lock shape alone, which matched none of the 123 modules on the game drives. On an Apple M6 a plain store published with stxr or stxp was read stale 12,858 and 10,961 times in 100 million rounds, never with stlxr or stlxp, and an atomic increment cost the same either way. Rewriting all 123 modules with and without the rule changes only those bits (1,045,422 in 78 modules, plus 1,499 copies in exclusive windows); Fire Emblem Engage and Super Mario Odyssey run the same with the modules swapped. The words stay out of changed.tsv, so cheats may still write them (first reported by the S2Arm project) |
+| [4139d41a](https://github.com/seedds/nxnative/commit/4139d41a) | Build / extraction / CLI | The iOS app is built for the M1, the oldest chip it supports, not the A18: AV1 movies no longer stop an M1 iPad with SIGILL in dav1d; and push_romfs.sh no longer empties the app's container, saves included, and sends the image over AFC, as devicectl reads a whole image into the Mac's memory (PR #1829) |
+| [09dcb627](https://github.com/seedds/nxnative/commit/09dcb627) | Shader / pipeline | A mesh shader with no task shader launches its workgroups through an object function of its own, in blocks of 1024: Metal holds a mesh grid to 1024 threadgroups, and with no object stage a draw's workgroups were the mesh grid, so Ocarina of Time's foliage drew up to 1,771 in one indirect draw, read out of bounds and page faulted the GPU on an M2 Max (issue #1326). The draw stays one Metal draw with no limit of its own (chain 38c7b39dc6fd; PR #1708) |
+| [270b6fd5](https://github.com/seedds/nxnative/commit/270b6fd5) | Host / runtime | A vertex stream in no memory pool is a red line instead of an earlier draw's buffer, and SetNumActiveTextures limits the swap chain textures AcquireTexture hands out: bind_vbufs reports the first stream the vertex shader reads whose non-zero address is in no pool, after binding the others, and the draw is skipped as for an index buffer in no pool (a transform feedback capture and the vertex dump too; a pulling pipeline reads zeros; a geometry capture, which shares its encoder with the job's later stages, logs and keeps the old binding). The window keeps the active count (win_t / win_builder_t nactive), the acquire rotates through it, and its getters answer it; every game in the Mac's run logs asks for its whole swap chain, and the S2Arm project reports Super Mario Bros. Wonder sets 2 of 3. t_vbuf and t_nvn check both (first reported by the S2Arm project) |
+| [dbd0a5ec](https://github.com/seedds/nxnative/commit/dbd0a5ec) | Docs / tests | Pause tests (red): a thread paused inside an SVC keeps paused set after the resume and leaves by the fast path, so the next SetThreadActivity(pause) returns while it runs guest code and GetThreadContext3 reports an all-zero context (t_pause cases 2-3; t_pause_stress, the stop-the-world pattern of IL2CPP's collector) |
+| [e00d55d4](https://github.com/seedds/nxnative/commit/e00d55d4) | Host / runtime | A resume clears paused, and a thread waiting for it in the pause signal handler sets it again on every wakeup, as the SVC exit does: a thread paused inside an SVC left by the lock-free exit with the flag still set, so the next SetThreadActivity(pause) returned while it ran guest code and GetThreadContext3 gave an all-zero context (Aokana: FMOD's stream thread decoded through IL2CPP's liveness marks and crashed in BufferedReadStream.Seek); clearing alone would leave a pause that comes before the woken handler retakes G waiting for ever |
+| [a1e155a3](https://github.com/seedds/nxnative/commit/a1e155a3) | Shader / pipeline | The pipeline archive bundled with the app is mapped too, not read into memory: it opened before the window in which a .mtl4a read asks for a mapping, so on an external drive Foundation read the whole file into memory (Ocarina of Time: footprint 19.0 → 12.2 GB, Malloc Large 5,032 → 135 MB, one 4.8 GB NSData). One window now covers every archive open in pso4_init, the bundled archive and the device's folder (PR #1817) |
+| [a342d640](https://github.com/seedds/nxnative/commit/a342d640) | Input / display / audio | Switch 2 controllers over Bluetooth LE on the Mac (Joy-Con 2, Pro Controller 2): an off-by-default setting, pairing by Sync during the game, motion, and Home through the hold-to-quit prompt (issue #1267) |
+| [8c436e33](https://github.com/seedds/nxnative/commit/8c436e33) | Input / display / audio | Rumble for Switch 2 controllers over Bluetooth LE on the full-key handles, coded as the console codes it: logarithmic frequency codes, the sample count, each of the game's values in its own 5 ms sample (2 of 3, issue #1267) |
+| [36e6e4ae](https://github.com/seedds/nxnative/commit/36e6e4ae) | Input / display / audio | Joy-Cons as dual or single npads: a pair is a JoyDual npad with a sensor per Joy-Con, splits when the game asks, and the Switch 2 Joy-Cons choose by a three-second hold (3 of 3, issue #1267) |
+| [413e8808](https://github.com/seedds/nxnative/commit/413e8808) | Input / display / audio | The Joy-Con 2's mouse sensor and pointer: a front end reports each Joy-Con's sensor (dm_joy_mouse), the game reads it as JcmsStates and as an Nmouse pointer, and a Joy-Con 2 pad shows the Joy-Con 2 npad styles (issue #1267, after #1363) |
+| [3376af3c](https://github.com/seedds/nxnative/commit/3376af3c) | Shader / pipeline | A 64- or 128-bit raw global load that misses every pool reads 0 in all its words again, and a vector load's follower scan takes only its own LDG's words: the vector helper returned 0 for word 0 but never wrote gmem_vec, so words 1..3 read the previous load's words or undefined data, and with two LDG.128 in one block a word of the second could read the first's (chain 5628839860cf, PR #1813) |
+| [f7a54525](https://github.com/seedds/nxnative/commit/f7a54525) | Shader / pipeline | A shader package's records carry their keys, and dmsh_build.py finds a map row's record by its key, not by the row's index: a package from another shader_split.py run than the map translated every row after a dropped or added one from its neighbour's bytes (issue #1841: Ocarina of Time built through NX2Apple kept ps_lib.bin's 4,711 records beside a map of 4,707 rows, and the present pass ran a terrain pixel shader, a black screen with sound); such a package now stops the shader build (chain 3df44a5a4e0c) |
+| [7479adf8](https://github.com/seedds/nxnative/commit/7479adf8) | Game profiles / frame rate | Reuse checked Unity bucket instruction decodes |
+| [4a815619](https://github.com/seedds/nxnative/commit/4a815619) | Shader / pipeline | A prewarm writes the pipeline archive a file per full period, not a file per 10 s timer flush, and what the archive holds unwritten is written at quit and starts writing on going to the background: on Ocarina of Time a cold start left 80 run files, so the next start compacted them all, 762 s before the first frame with 0 compiled in the log; now the cold start leaves 5 files and the next start takes 3.3 s (PR #1879, issue #1747) |
+| [3870fcc0](https://github.com/seedds/nxnative/commit/3870fcc0) | Host / runtime | GPU copies and render passes into a 3D texture uploaded from its storage are written into its storage slice by slice, as for 2D textures: nvn_tex_mirror_b2t and nvn_tex_mirror_t2t refused 3D, and copy_b2t, copy_t2t and rt_mirror_add skipped the readback for it, so a 3D copy reached only the host texture (issue #1856: LEGO Star Wars: The Skywalker Saga fills its BC6H light-probe volumes a slice at a time with buffer copies, then moves them into new textures by copying their storage; the moved volumes held the pool's old bytes and every dynamically lit object came out at the half-float maximum, white). write_region_bl takes the depth slice (nvn_bl_offset already interleaves slices by the level's block depth), a region's images are the depth slices of a 3D texture's one layer (region_image), the readback reads a 3D texture's slices at origin z, and WriteTexels writes a 3D region in place as well |
+| [143c0d09](https://github.com/seedds/nxnative/commit/143c0d09) | Game profiles / frame rate | Hyrule Warriors: Age of Calamity v458752 plays at 60 fps and Tears of the Kingdom's frame step is game-bound: both are placed by their functions (MATCH_FRAME_STEP with others), a matched stub reads the globals it needs from its functions' adrp pairs (ctx.address), and a derived profile keeps fps_target when the profile has a function-matched frame step (issue #635) |
+| [4c3a03af](https://github.com/seedds/nxnative/commit/4c3a03af) | Shader / pipeline | Pipeline archive compaction takes run files by size tier from the pipeline count in their names, never a full period's file: on Ocarina of Time 9 files of play beside a compaction's 5 made the next start capture 8,543 archived pipelines again, 713 s before the first frame on an M4; now that start takes 23.6 s, compiling only the 262 bases new to the list (PR #1880) |
+| [d902bc66](https://github.com/seedds/nxnative/commit/d902bc66) | Docs / tests | t_render_enable_queue and t_render_counter_overlap turn the profile key render_cond on themselves: they check its replay (render-enable blocks, pending counter values), which a profile without the key leaves out by design, so under Breath of the Wild's profile 9 of 46 and 1 of 42 checks failed while Ocarina of Time's passed |
+| [a9ee2476](https://github.com/seedds/nxnative/commit/a9ee2476) | Game profiles / frame rate | NieR:Automata's cuts and host table stay build-bound, kept: none of their sites has an exact match in ASTRAL CHAIN or Bayonetta 1-3, so game is their loosest binding, and 1.0.3 is likely both releases' last update; moving the host table would also need matched rules that write fixups (issue #635) |
+| [e4e5ff64](https://github.com/seedds/nxnative/commit/e4e5ff64) | Shader / pipeline | Translated shaders compile with -fwrapv: Ocarina of Time's mesh-shader models no longer take their ambient light from the wrong probe texels (the bright band on a dark wall in Gohma's room) |
+| [7a7acfee](https://github.com/seedds/nxnative/commit/7a7acfee) | Build / extraction / CLI | An iOS build keeps the pulling vertex libraries whatever the profile's pso_mtl4 says: the launcher's Metal 4 pipelines toggle turns the path on for any NVN game, and its prebuild builds the bases from them, so on Echoes of Wisdom both toggles built nothing (PR #1758, issue #1684) |
+| [2ae28018](https://github.com/seedds/nxnative/commit/2ae28018) | Docs / tests | docs: list community front ends with short descriptions |
+| [70b9a68a](https://github.com/seedds/nxnative/commit/70b9a68a) | Shader / pipeline | Each pipeline archive file has an index beside it, so a lookup asks the one file that holds the pipeline and a miss asks none, whatever the number of files: a lookup asked every file in turn (about 0.06 ms each one it missed in), which is why a few files had to make a start a compaction that compiles every base again under the #342 workaround (Ocarina of Time on an M4: 713 s after play, PR #1880; 762 s after a cold start's 80 files, issue #1747) |
+| [3303b5a7](https://github.com/seedds/nxnative/commit/3303b5a7) | Build / extraction / CLI | The prebuild stamp names a library file the run lacked, and counts as done only while the library still lacks it: an IPA packed without the pulling libraries, run with DM_PSO_MTL4=1, stamped its prebuild as finished, so an app that gained them never built their bases (PR #1177) |
+| [6cbb87c9](https://github.com/seedds/nxnative/commit/6cbb87c9) | Docs / tests | README lists Switch 2 games without screenshots |
+| [fed627bc](https://github.com/seedds/nxnative/commit/fed627bc) | Build / extraction / CLI | The NSP script takes an app icon: --icon / ICON, a JPEG or PNG of any size, before the control data's icon, which a dump never has and a NUP may lack; such builds had a blank icon and no way to give them one (an icon put in extract// went with the next fresh unpack). The path is checked before anything is extracted (PR #1551) |
+| [06973c5f](https://github.com/seedds/nxnative/commit/06973c5f) | Shader / pipeline | Tears of the Kingdom's vertex-shader visibility counter stays a single-shader rule, kept: the shader-build logs of the 51 game builds on the drive show no shader failing to translate or compile and no subgroup operation, so a general ballot lowering would be a chain change for no measured gain (issue #635) |
+| [e82888e6](https://github.com/seedds/nxnative/commit/e82888e6) | Docs / tests | README puts community front ends directly below the games list |
+| [7ffa63cc](https://github.com/seedds/nxnative/commit/7ffa63cc) | Docs / tests | Bananza records the community iPhone test from issue #1183 |
+| [cfbdd93c](https://github.com/seedds/nxnative/commit/cfbdd93c) | Host / runtime | The samples-passed counter is one running total in queue order: a game that never resets it reads its occlusion queries again (Red Dead Redemption's distant characters had no bodies), and a report a submission after its reset no longer reads slots a later submission overwrote (PR #1867) |
+| [cb4a97a6](https://github.com/seedds/nxnative/commit/cb4a97a6) | Build / extraction / CLI | A folder whose name has brackets no longer hides files from the tools that search it: the rewriter loaded no game's patches.py (no cuts, observation gates or frame-step stubs, and no error), the matcher found no registered cuts, a provisional profile was guessed with no host hooks, and the shader split found no NRO captured from a boot (PR #1826) |
+| [d9cd9de7](https://github.com/seedds/nxnative/commit/d9cd9de7) | Build / extraction / CLI | A program's DLC are numbered from the application's title ID: a program packed alone (--program N) or with its launcher had every DLC image N below its index, so METAL GEAR SOLID: Peace Walker - Master Collection Version's program 1 mounted the Pre-Order Bonus where it asked for the Multilingual Pack and ended at start (PR #1874) |
+| [7238bfd1](https://github.com/seedds/nxnative/commit/7238bfd1) | Host / runtime | A Core Haptics engine that stops under a call raises an NSException: the rumble adapter drops it as a failed engine instead of ending the app (PR #1694) |
+| [79741184](https://github.com/seedds/nxnative/commit/79741184) | Host / runtime | H.264 movie packets are read as Annex B only: a 4-byte NAL length from 256 to 511 begins 00 00 01, so the length-prefixed fallback could not be told from a start code (issue #629) |
+
+Latest automatic verification after the 120 FPS persistence check: **1,166 tests, zero failures** against candidate 79741184. No production behavior changed in this follow-up; no game was rebuilt or launched by the agent.
diff --git a/docs/upstream-upgrades/README.md b/docs/upstream-upgrades/README.md
index 0945ee2..7c8d74e 100644
--- a/docs/upstream-upgrades/README.md
+++ b/docs/upstream-upgrades/README.md
@@ -76,3 +76,7 @@ This directory records the review that must happen before the pin changes, plus
- [2026-11-07 — b2d46b86 → 7fe9fc4d](2026-11-07-b2d46b86-7fe9fc4d-review.md) (0.1.4.1: Ocarina of Time rebuild confirmed by the maintainer without the dotted rows; terrain holes and white blotches fixed upstream)
- [2026-11-07 — 7fe9fc4d → e5f03403](2026-11-07-7fe9fc4d-e5f03403-review.md) (0.1.4.1: Ocarina of Time rebuild confirmed by the maintainer; packages read by their content metas, NSP with keys still to check by hand)
- [2026-11-08 — e5f03403 → bd979ced](2026-11-08-e5f03403-bd979ced-review.md) (0.1.4.1 candidate: Ocarina's foliage no longer flashes white, Pikmin 4 at 60 fps; shaders translate again; awaiting the maintainer's rebuild)
+
+## Maintainer-directed adoption
+
+- [2026-11-09 — bd979ced → 79741184](2026-11-09-bd979ced-79741184-review.md) (0.1.4.2: 71 commits; adopted at the maintainer's explicit request after automated review/gate checks; exact-hash real game build/launch remains unconfirmed).
|