diff --git a/dm/games/settings.py b/dm/games/settings.py index 506134421a..4670f084f6 100644 --- a/dm/games/settings.py +++ b/dm/games/settings.py @@ -112,6 +112,8 @@ def offered(v, n): # value=label?cond&cond: an option only some games or platf if can is not None and want not in can: sys.exit(f'settings.tsv:{n}: needs a value {need} never takes: {want} (it takes {", ".join(sorted(can))})') if not shown(cond, n): continue s = {'env': var, 'kind': kind, 'section': section, 'label': label, 'note': note, 'default': value(default, n), **m} + s.update(label_key=f'setting.{var}.label', section_key=f'section.{section}') + if note: s['note_key'] = f'setting.{var}.note' if var == 'DM_RENDER_SCALE' and base: s['base'] = base # the render scale in pixels, for the console mode chosen if kind == 'choice': opts = [list(l) for l in langs] if values == 'languages' else [v.split('?', 1)[0].split('=', 1) for v in values.split(',') if offered(v, n)] @@ -119,6 +121,7 @@ def offered(v, n): # value=label?cond&cond: an option only some games or platf if s['default'] not in [o[0] for o in opts]: s['default'] = opts[0][0] # a default the game does not offer (a language it lacks): its first s['options'] = opts if values == 'languages' and not have: s['note'] = UNKNOWN_LANGUAGES + s['option_keys'] = {o[0]: f'setting.{var}.option.{o[0]}' for o in opts} elif kind == 'range': lo, hi, st = (float(x) for x in values.split(':')) if var == 'DM_RENDER_SCALE' and keys.get('RENDER_SCALE', '1') != '1': # a stub that also raises the size: up to its largest scale (the rewriter's RENDER_SCALE, hex as every header value) diff --git a/dm/ios/Sources/Launcher.swift b/dm/ios/Sources/Launcher.swift index 0f9d8b6639..4ce9a23724 100644 --- a/dm/ios/Sources/Launcher.swift +++ b/dm/ios/Sources/Launcher.swift @@ -21,32 +21,32 @@ private struct CloudSavesSection: View { var body: some View { Section { HStack { - Text("Cloud saves") + Text(launcherString("Cloud saves")) Spacer() - Text(folder.isEmpty ? "Off" : (folder as NSString).lastPathComponent) + Text(folder.isEmpty ? launcherString("Off") : (folder as NSString).lastPathComponent) .foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) } - Button(folder.isEmpty ? "Choose a folder in iCloud Drive…" : "Choose a different folder…") { picking = true } + Button(folder.isEmpty ? launcherString("Choose a folder in iCloud Drive…") : launcherString("Choose a different folder…")) { picking = true } // The sheet sits on the button, not on the Section: SwiftUI hands a Section's modifiers to every row, so one tap presented a picker per row and they invalidated each other (LNPANDY/NX2Apple#315). .sheet(isPresented: $picking) { DocumentPicker(types: [.folder]) { url in picking = false if dm_store_cloud_saves(url) { - note = "Saved. The game uses this folder when you press Play." + note = launcherString("Saved. The game uses this folder when you press Play.") refresh() } else { - note = "That folder could not be remembered — try a different one." + note = launcherString("That folder could not be remembered — try a different one.") } } onCancel: { picking = false } } if !folder.isEmpty { - Button("Turn off cloud saves", role: .destructive) { + Button(launcherString("Turn off cloud saves"), role: .destructive) { dm_clear_cloud_saves(); refresh(); note = "" } } } footer: { Text(note.isEmpty - ? "Choose a folder in iCloud Drive, and the same one in the Mac app's Cloud saves. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in save-backups." + ? launcherString("Choose a folder in iCloud Drive, and the same one in the Mac app's Cloud saves. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in save-backups.") : note) } .onAppear(perform: refresh) @@ -56,7 +56,7 @@ private struct CloudSavesSection: View { var stale: ObjCBool = false folder = dm_cloud_saves_path(&stale) ?? "" if stale.boolValue && folder.isEmpty { - note = "The folder you chose before is no longer readable (the app was signed differently). Please choose it again." + note = launcherString("The folder you chose before is no longer readable (the app was signed differently). Please choose it again.") } } } @@ -110,9 +110,9 @@ private struct LauncherView: View { .clipShape(RoundedRectangle(cornerRadius: 22)) .accessibilityHidden(true) } - Text((info("CFBundleDisplayName") as? String) ?? "Game") + Text((info("CFBundleDisplayName") as? String) ?? launcherString("Game")) .font(.title.weight(.bold)).multilineTextAlignment(.center) - Text("Version \((info("CFBundleShortVersionString") as? String) ?? "")") + Text(String(format: launcherString("Version %@"), (info("CFBundleShortVersionString") as? String) ?? "")) .font(.callout).foregroundStyle(.secondary) playButton } @@ -122,21 +122,21 @@ private struct LauncherView: View { ForEach(m.sections, id: \.self) { section in let rows = m.rows(in: section) if !rows.isEmpty { - Section(section) { + Section(rows[0].displaySection()) { ForEach(rows) { SettingRow(s: $0, m: m) } } } } Section { - Button("Reset to tested defaults") { m.reset() } + Button(launcherString("Reset to tested defaults")) { m.reset() } .disabled(!m.changed) } footer: { - Text("Brings back all settings this game was tested with.") + Text(launcherString("Brings back all settings this game was tested with.")) } CloudSavesSection() Section { HStack { - Text("Memory available") + Text(launcherString("Memory available")) Spacer() Text(ByteCountFormatter.string(fromByteCount: Int64(memory), countStyle: .memory)).monospacedDigit().foregroundStyle(.secondary) } @@ -151,12 +151,12 @@ private struct LauncherView: View { .confirmationDialog(SaveSyncReport.chooseTitle, isPresented: isAsking(.choose), titleVisibility: .visible, presenting: report) { r in Button(r.localLabel(here: "iPhone")) { go(r.localChoice) } Button(r.cloudLabel) { go(r.cloudChoice) } - Button("Cancel", role: .cancel) {} + Button(launcherString("Cancel"), role: .cancel) {} } message: { r in Text(r.chooseMessage(here: "iPhone")) } .confirmationDialog(SaveSyncReport.unreadableTitle, isPresented: isAsking(.unreadable), titleVisibility: .visible, presenting: report) { r in - Button("Play This iPhone's Save") { go("offline") } // the mount does not wait for the cloud again - Button("Try Again") { DispatchQueue.main.async { start() } } - Button("Cancel", role: .cancel) {} + Button(launcherString("Play This iPhone's Save")) { go("offline") } // the mount does not wait for the cloud again + Button(launcherString("Try Again")) { DispatchQueue.main.async { start() } } + Button(launcherString("Cancel"), role: .cancel) {} } message: { r in Text(r.unreadableMessage(here: "iPhone")) } .task { while !Task.isCancelled { @@ -167,12 +167,12 @@ private struct LauncherView: View { } @ViewBuilder var playButton: some View { - let button = Button(action: start) { Text("Play").font(.title3.weight(.semibold)).frame(maxWidth: 220) }.controlSize(.large).disabled(checking) + let button = Button(action: start) { Text(launcherString("Play")).font(.title3.weight(.semibold)).frame(maxWidth: 220) }.controlSize(.large).disabled(checking) if #available(iOS 26.0, *) { button.buttonStyle(.glassProminent) } else { button.buttonStyle(.borderedProminent) } if checking { VStack(spacing: 6) { - HStack(spacing: 6) { ProgressView(); Text("Checking the cloud save…").font(.callout).foregroundStyle(.secondary) } - Button("Play Without It") { checking = false; go("offline") }.font(.callout) + HStack(spacing: 6) { ProgressView(); Text(launcherString("Checking the cloud save…")).font(.callout).foregroundStyle(.secondary) } + Button(launcherString("Play Without It")) { checking = false; go("offline") }.font(.callout) } } } diff --git a/dm/ios/Sources/LauncherRow.swift b/dm/ios/Sources/LauncherRow.swift index 82fa3b3c13..4430671ee4 100644 --- a/dm/ios/Sources/LauncherRow.swift +++ b/dm/ios/Sources/LauncherRow.swift @@ -12,8 +12,9 @@ struct SettingRow: View { private var locked: Bool { m.fixed.contains(s.env) } private var note: String { - guard locked else { return s.note } - return (s.note.isEmpty ? "" : s.note + " · ") + (m.fromLaunch.contains(s.env) ? "Set at launch" : "Set by an env file") + let text = s.displayNote() + guard locked else { return text } + return (text.isEmpty ? "" : text + " · ") + launcherString(m.fromLaunch.contains(s.env) ? "Set at launch" : "Set by an env file") } private var rangeValue: Binding { Binding(get: { Double(m.values[s.env] ?? s.def) ?? s.max ?? 1 }, @@ -23,7 +24,7 @@ struct SettingRow: View { private var settingLabel: some View { VStack(alignment: .leading, spacing: 4) { - Text(s.label) + Text(s.displayLabel()) if !note.isEmpty { Text(note).font(.footnote).foregroundStyle(.secondary) } } } @@ -33,7 +34,7 @@ struct SettingRow: View { switch s.kind { case "choice": Picker(selection: m.binding(s)) { - ForEach(s.options ?? [], id: \.[0]) { Text($0[1]).tag($0[0]) } + ForEach(s.options ?? [], id: \.[0]) { Text(s.displayOption($0)).tag($0[0]) } } label: { settingLabel } @@ -46,7 +47,7 @@ struct SettingRow: View { Text(percentage).monospacedDigit().foregroundStyle(.secondary) } Slider(value: rangeValue, in: (s.min ?? 0)...(s.max ?? 1), step: s.step ?? 0.05) - .accessibilityLabel(s.label) + .accessibilityLabel(s.displayLabel()) .accessibilityValue(percentage) } default: diff --git a/dm/ios/Sources/LauncherSettings.swift b/dm/ios/Sources/LauncherSettings.swift index aec4414c8a..21c6b59156 100644 --- a/dm/ios/Sources/LauncherSettings.swift +++ b/dm/ios/Sources/LauncherSettings.swift @@ -2,6 +2,13 @@ import Foundation import Combine +// The shared Launcher.strings table follows the app's preferred language, independently of DM_LANG. +// Missing keys (including older launcher.json files) keep their English text. +func launcherString(_ key: String?, fallback: String? = nil, bundle: Bundle = .main) -> String { + guard let key else { return fallback ?? "" } + return bundle.localizedString(forKey: key, value: fallback ?? key, table: "Launcher") +} + struct Setting: Decodable, Identifiable { let env, kind, label, note, def: String let section: String? // heading on the settings screen; absent in older bundles @@ -9,11 +16,17 @@ struct Setting: Decodable, Identifiable { let level: String? // "basic": shown first (the Mac window's Basic settings); any other or none: Advanced settings. The iPhone keeps one list let base: Base? // render scale: the 3D views' size at 1.0, docked and handheld, for the size in pixels (the Mac window) struct Base: Decodable { let docked, handheld: [Int] } + let labelKey, noteKey, sectionKey: String? + let optionKeys: [String: String]? let options: [[String]]? // choice: [value, label] let min, max, step: Double? // range let on, off: String? // toggle: the values the variable takes (1 / 0 when not given) var id: String { env } var sectionTitle: String { section ?? "Game" } + func displayLabel(bundle: Bundle = .main) -> String { launcherString(labelKey, fallback: label, bundle: bundle) } + func displayNote(bundle: Bundle = .main) -> String { launcherString(noteKey, fallback: note, bundle: bundle) } + func displaySection(bundle: Bundle = .main) -> String { launcherString(sectionKey, fallback: sectionTitle, bundle: bundle) } + func displayOption(_ option: [String], bundle: Bundle = .main) -> String { launcherString(optionKeys?[option[0]], fallback: option[1], bundle: bundle) } var basic: Bool { level == "basic" } var onValue: String { on ?? "1" } var offValue: String { off ?? "0" } @@ -30,7 +43,9 @@ struct Setting: Decodable, Identifiable { default: return true } } - enum CodingKeys: String, CodingKey { case env, kind, label, note, def = "default", section, needs, level, base, options, min, max, step, on, off } + enum CodingKeys: String, CodingKey { case env, kind, label, note, def = "default", section, needs, level, base, options, min, max, step, on, off + case labelKey = "label_key", noteKey = "note_key", sectionKey = "section_key", optionKeys = "option_keys" + } } struct SettingsFile: Decodable { let settings: [Setting] } diff --git a/dm/ios/Sources/Resources/en.lproj/Launcher.strings b/dm/ios/Sources/Resources/en.lproj/Launcher.strings new file mode 100644 index 0000000000..94c4d3bbdf --- /dev/null +++ b/dm/ios/Sources/Resources/en.lproj/Launcher.strings @@ -0,0 +1,106 @@ +// Shared iOS and Mac launcher text. Translate values, preserving keys and %@ placeholders. +// settings.tsv remains the English fallback for generated settings; see docs/frontends.md. +"Game" = "Game"; +"Reset to tested defaults" = "Reset to tested defaults"; +"Brings back all settings this game was tested with." = "Brings back all settings this game was tested with."; +"Play" = "Play"; +"Cloud saves" = "Cloud saves"; +"Off" = "Off"; +"Choose a different folder…" = "Choose a different folder…"; +"Choose a folder in iCloud Drive…" = "Choose a folder in iCloud Drive…"; +"Turn off cloud saves" = "Turn off cloud saves"; +"Choose a folder in iCloud Drive, and the same one in the Mac app's Cloud saves. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in save-backups." = "Choose a folder in iCloud Drive, and the same one in the Mac app's Cloud saves. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in save-backups."; +"Saved. The game uses this folder when you press Play." = "Saved. The game uses this folder when you press Play."; +"That folder could not be remembered — try a different one." = "That folder could not be remembered — try a different one."; +"The folder you chose before is no longer readable (the app was signed differently). Please choose it again." = "The folder you chose before is no longer readable (the app was signed differently). Please choose it again."; +"Memory available" = "Memory available"; +"Advanced settings" = "Advanced settings"; +"Show this window at launch" = "Show this window at launch"; +"Quit" = "Quit"; +"Choose a folder…" = "Choose a folder…"; +"Turn off" = "Turn off"; +"Choose the iCloud Drive folder the iPhone app uses for this game. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in the save's -backups folder." = "Choose the iCloud Drive folder the iPhone app uses for this game. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in the save's -backups folder."; +"Use for Cloud Saves" = "Use for Cloud Saves"; +"The folder the iPhone app chose in its launcher's Cloud saves" = "The folder the iPhone app chose in its launcher's Cloud saves"; +"iCloud Drive" = "iCloud Drive"; +"Close Window" = "Close Window"; +"Set at launch" = "Set at launch"; +"Set by an env file" = "Set by an env file"; +"Version %@" = "Version %@"; +"Quit %@" = "Quit %@"; +"%@ (env.txt)" = "%@ (env.txt)"; +"section.Game" = "Game"; +"section.Controls" = "Controls"; +"section.Performance" = "Performance"; +"setting.DM_FPS_TARGET.label" = "Frame rate"; +"setting.DM_FPS_TARGET.option.30" = "30 fps"; +"setting.DM_FPS_TARGET.option.60" = "60 fps"; +"setting.DM_FPS_TARGET.option.120" = "120 fps"; +"setting.DM_DYNAMIC_RESOLUTION.label" = "Dynamic resolution"; +"setting.DM_DYNAMIC_RESOLUTION.note" = "Lets the game lower its resolution under load. Turn off to keep full resolution for the selected console mode."; +"setting.DM_RENDER_SCALE.label" = "Render scale"; +"setting.DM_OPMODE.label" = "Console mode"; +"setting.DM_OPMODE.option.1" = "Docked"; +"setting.DM_OPMODE.option.0" = "Handheld"; +"setting.DM_PRESENT_UPSCALE.label" = "Upscaling"; +"setting.DM_PRESENT_UPSCALE.note" = "Uses more GPU time and can increase heat."; +"setting.DM_PRESENT_UPSCALE.option.off" = "Standard"; +"setting.DM_PRESENT_UPSCALE.option.metalfx" = "MetalFX"; +"setting.DM_LANG.label" = "Game language"; +"setting.DM_LANG.option.en-US" = "American English"; +"setting.DM_LANG.option.en-GB" = "British English"; +"setting.DM_LANG.option.ja" = "Japanese"; +"setting.DM_LANG.option.fr" = "French"; +"setting.DM_LANG.option.de" = "German"; +"setting.DM_LANG.option.es-419" = "Latin American Spanish"; +"setting.DM_LANG.option.es" = "Spanish"; +"setting.DM_LANG.option.it" = "Italian"; +"setting.DM_LANG.option.nl" = "Dutch"; +"setting.DM_LANG.option.fr-CA" = "Canadian French"; +"setting.DM_LANG.option.pt" = "Portuguese"; +"setting.DM_LANG.option.ru" = "Russian"; +"setting.DM_LANG.option.ko" = "Korean"; +"setting.DM_LANG.option.zh-Hant" = "Traditional Chinese"; +"setting.DM_LANG.option.zh-Hans" = "Simplified Chinese"; +"setting.DM_LANG.option.pt-BR" = "Brazilian Portuguese"; +"setting.DM_FULLSCREEN.label" = "Display mode"; +"setting.DM_FULLSCREEN.option.0" = "Window"; +"setting.DM_FULLSCREEN.option.1" = "Fullscreen"; +"setting.DM_SHOW_FPS.label" = "Frame-rate counter"; +"setting.DM_TOUCH_HAPTICS.label" = "Touch feedback"; +"setting.DM_RUMBLE.label" = "Game rumble"; +"setting.DM_RUMBLE_GAIN.label" = "Rumble strength"; +"setting.DM_RUMBLE_MIN.label" = "Rumble cutoff"; +"setting.DM_PAD_PRESS_TO_JOIN.label" = "Press a button to join"; +"setting.DM_SW2.label" = "Switch 2 controllers"; +"setting.DM_SW2.note" = "Plays Joy-Con 2 and Pro Controller 2 over Bluetooth: hold Sync on one during the game to pair it. macOS asks once for Bluetooth access."; +"setting.DM_PRESENT_SNAP.label" = "Frame buffering"; +"setting.DM_PRESENT_SNAP.note" = "Up to one frame more display latency."; +"setting.DM_PSO_MTL4.label" = "Metal 4 pipelines"; +"setting.DM_PSO_MTL4.note" = "Keeps a pipeline archive; uses more memory."; +"setting.DM_PSO_PREBUILD.label" = "Pipeline prebuild"; +"setting.DM_PSO_PREBUILD.note" = "The first start waits a few minutes for it, once."; +"setting.DM_NVN_ENC_THREAD.label" = "Encode thread"; +"setting.DM_FPS_HOLD.label" = "Hold frames when late"; +"setting.DM_METAL_HUD.label" = "Performance HUD"; + +"setting.DM_UPSCALER.label" = "Game's upscaler"; +"setting.DM_UPSCALER.option.game" = "Game's own"; +"setting.DM_UPSCALER.option.metalfx" = "MetalFX"; +"setting.DM_PICTURE_FIT.label" = "Picture"; +"setting.DM_PICTURE_FIT.note" = "Fill crops the game's 16:9 picture to cover the screen. Health bars, subtitles or maps near the edges can be cut off. A full-screen picture without cropping needs a patch for each game."; +"setting.DM_PICTURE_FIT.option.fit" = "Fit"; +"setting.DM_PICTURE_FIT.option.fill" = "Fill"; +"setting.DM_SCREEN_ASPECT.label" = "Picture shape"; +"setting.DM_SCREEN_ASPECT.note" = "Renders about a quarter more pixels: a little slower and warmer."; +"setting.DM_SCREEN_ASPECT.option.auto" = "Match screen"; +"setting.DM_SCREEN_ASPECT.option.1.43" = "iPhone Duo screen (1.43:1)"; +"setting.DM_SCREEN_ASPECT.option.0" = "16:9 (Switch)"; + +// Cloud-save controls added by the shared launcher flow. +"Cancel" = "Cancel"; +"Checking the cloud save…" = "Checking the cloud save…"; +"Play This Mac's Save" = "Play This Mac's Save"; +"Play This iPhone's Save" = "Play This iPhone's Save"; +"Play Without It" = "Play Without It"; +"Try Again" = "Try Again"; diff --git a/dm/ios/project.yml b/dm/ios/project.yml index d89d0aed8f..a919cbe5bb 100644 --- a/dm/ios/project.yml +++ b/dm/ios/project.yml @@ -1,6 +1,7 @@ # iOS project (xcodegen); build_ipa.sh generates Gen/ and the game resource paths first. Xcode stages resources before signing; the unsigned IPA command skips that copy and packs directly from the external drive. name: DmIOS options: + developmentLanguage: en bundleIdPrefix: ${DM_BUNDLE_ID} deploymentTarget: iOS: "26.0" # Graphics replay uses Metal 4 (MTL4CommandQueue etc., from iOS 26) diff --git a/dm/ios/tests/launcher_localization_check.swift b/dm/ios/tests/launcher_localization_check.swift new file mode 100644 index 0000000000..e1d00d2d72 --- /dev/null +++ b/dm/ios/tests/launcher_localization_check.swift @@ -0,0 +1,55 @@ +// Native bundle lookup with a synthetic translation, plus legacy JSON and missing-key fallback. +import Foundation + +@main struct LocalizationCheck { + static func main() throws { + let file = try JSONDecoder().decode(SettingsFile.self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))) + let env = Dictionary(uniqueKeysWithValues: file.settings.map { ($0.env, $0) }) + if CommandLine.arguments.contains("--english") { + precondition(Bundle.main.preferredLocalizations.first == "en", "unsupported locale did not fall back to English") + precondition(launcherString("Play") == "Play") + precondition(env["DM_OPMODE"]!.displayLabel() == "Console mode") + precondition(env["DM_OPMODE"]!.displayOption(["0", "Handheld"]) == "Handheld") + precondition(env["DM_OPMODE"]!.displaySection() == "Game") + print("launcher localization: passed") + return + } + precondition(Bundle.main.preferredLocalizations.first == "fr", "fixture language was not selected") + precondition(launcherString("Play") == "Translated play") + precondition(launcherString("Try Again") == "Translated retry") + precondition(launcherString("Checking the cloud save…") == "Checking the cloud save…") + precondition(launcherString("Unknown button") == "Unknown button") + precondition(String(format: launcherString("Version %@"), "1.2.3") == "1.2.3 translated version") + let mode = env["DM_OPMODE"]! + precondition(mode.displayLabel() == "Translated console mode") + precondition(mode.displaySection() == "Translated section") + precondition(mode.displayOption(["0", "Handheld"]) == "Translated handheld") + precondition(mode.displayOption(["1", "Docked"]) == "Docked") // missing translation uses JSON text + precondition(mode.displayNote().isEmpty) + let upscale = env["DM_PRESENT_UPSCALE"]! + precondition(upscale.displayNote() == "Translated GPU note") + precondition(env["DM_LANG"]!.displayOption(["ja", "Japanese"]) == "Translated Japanese") + // A front end's JSON predating the optional keys is still readable, and an unknown future key stays readable too. + for key in [nil, "setting.FUTURE.label"] as [String?] { + var row: [String: Any] = ["env": "DM_TEST", "kind": "toggle", "label": "Legacy label", "note": "Legacy note", "default": "0"] + row["label_key"] = key + let old = try JSONDecoder().decode(Setting.self, from: JSONSerialization.data(withJSONObject: row)) + precondition(old.displayLabel() == "Legacy label" && old.displayNote() == "Legacy note") + precondition(old.displaySection() == "Game" && old.onValue == "1" && old.offValue == "0") + } + let suite = "org.nxnative.localization-test." + UUID().uuidString + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let model = LauncherModel(file.settings, defaults: defaults) + // Both headings have the same synthetic translation; grouping and persistence still use raw identities and values. + precondition(model.sections.contains("Game") && model.sections.contains("Controls")) + precondition(model.rows(in: "Game").contains { $0.env == "DM_OPMODE" }) + precondition(!model.rows(in: "Controls").contains { $0.env == "DM_OPMODE" }) + model.values["DM_OPMODE"] = "0" + model.apply() + precondition(defaults.string(forKey: "launcher.DM_OPMODE") == "0") + precondition(String(cString: getenv("DM_OPMODE")) == "0") + precondition(model.values["DM_LANG"] == env["DM_LANG"]!.def) // launcher locale does not select the game language + print("launcher localization: passed") + } +} diff --git a/dm/ios/tests/test_launcher_localization.py b/dm/ios/tests/test_launcher_localization.py new file mode 100644 index 0000000000..c2728ccd35 --- /dev/null +++ b/dm/ios/tests/test_launcher_localization.py @@ -0,0 +1,82 @@ +"""Use real Apple string tables and the production settings model, without game files or a UI.""" +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / 'dm/games')) +import settings + + +@unittest.skipUnless(sys.platform == 'darwin' and shutil.which('xcrun'), 'needs the macOS Swift toolchain') +class LauncherLocalizationTests(unittest.TestCase): + def test_english_catalog_covers_the_settings_table(self): + path = REPO / 'dm/ios/Sources/Resources/en.lproj/Launcher.strings' + catalog = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', str(path)], text=True)) + for section in settings.SECTIONS: + self.assertEqual(catalog['section.' + section], section) + for _, (env, kind, values, _, _, _, label, note, _) in settings.rows(): + with self.subTest(env=env): + self.assertEqual(catalog[f'setting.{env}.label'], label) + if note: + self.assertEqual(catalog[f'setting.{env}.note'], note) + if kind == 'choice': + options = settings.nacp.LANGUAGES if values == 'languages' else [v.split('?', 1)[0].split('=', 1) for v in values.split(',')] + for value, text in options: + self.assertEqual(catalog[f'setting.{env}.option.{value}'], text) + + def test_literal_ui_keys_are_in_the_catalog(self): + path = REPO / 'dm/ios/Sources/Resources/en.lproj/Launcher.strings' + catalog = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', str(path)], text=True)) + for source in ('dm/ios/Sources/Launcher.swift', 'dm/ios/Sources/LauncherRow.swift', 'dm/mac/Launcher.swift'): + for literal in re.findall(r'launcherString\(("(?:[^"\\]|\\.)*")', (REPO / source).read_text()): + key = json.loads(literal) + with self.subTest(source=source, key=key): + self.assertEqual(catalog[key], key) + + def test_native_translation_fallback_and_raw_values(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + app = root / 'Fixture.app/Contents' + exe = app / 'MacOS/check' + exe.parent.mkdir(parents=True) + resources = app / 'Resources' + resources.mkdir() + shutil.copytree(REPO / 'dm/ios/Sources/Resources/en.lproj', resources / 'en.lproj') + (app / 'Info.plist').write_bytes(plistlib.dumps({'CFBundleIdentifier': 'org.nxnative.localization-test', + 'CFBundleExecutable': 'check', 'CFBundleDevelopmentRegion': 'en', 'CFBundlePackageType': 'APPL'})) + translated = resources / 'fr.lproj' + translated.mkdir() + strings = {'Play': 'Translated play', 'Try Again': 'Translated retry', 'Version %@': '%1$@ translated version', + 'setting.DM_OPMODE.label': 'Translated console mode', 'setting.DM_OPMODE.option.0': 'Translated handheld', + 'setting.DM_PRESENT_UPSCALE.note': 'Translated GPU note', 'setting.DM_LANG.option.ja': 'Translated Japanese', + 'section.Game': 'Translated section', 'section.Controls': 'Translated section'} + (translated / 'Launcher.strings').write_text('\n'.join(f'{json.dumps(k)} = {json.dumps(v)};' for k, v in strings.items())) + with patch.dict(os.environ, {'GFXAPI': 'nvn', 'SETTINGS_PLATFORM': 'mac', 'DM_LANG': 'en-US'}), \ + patch.object(settings.nacp, 'languages', return_value=['en-US', 'ja']): + data = settings.build('01007EF00011E000', root, root / 'control.nacp') + (root / 'launcher.json').write_text(json.dumps(data)) + command = ['xcrun', '-sdk', 'macosx', 'swiftc', '-swift-version', '5', '-parse-as-library', + '-module-cache-path', str(root / 'module-cache'), '-o', str(exe), + str(REPO / 'dm/ios/Sources/LauncherSettings.swift'), str(Path(__file__).with_name('launcher_localization_check.swift'))] + compile_result = subprocess.run(command, capture_output=True, text=True, timeout=300) + self.assertEqual(compile_result.returncode, 0, compile_result.stdout + compile_result.stderr) + env = {k: v for k, v in os.environ.items() if not k.startswith('DM_')} + for language, extra in [('fr', []), ('de', ['--english'])]: # no German table in this fixture + with self.subTest(language=language): + result = subprocess.run([str(exe), str(root / 'launcher.json'), '-AppleLanguages', f'({language})', *extra], env=env, + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('launcher localization: passed', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/dm/ios/tools/build_smoke.py b/dm/ios/tools/build_smoke.py index aa5732a822..362d3090b5 100644 --- a/dm/ios/tools/build_smoke.py +++ b/dm/ios/tools/build_smoke.py @@ -96,6 +96,13 @@ def run(*command, capture=False): (shaders / marker, app / 'msl' / f'b-{chain}' / marker)): if source.read_bytes() != packed.read_bytes(): raise RuntimeError(f'Resource staging failed: {packed}') + for table in (ios / 'Sources/Resources').glob('*.lproj/Launcher.strings'): + packed = app / table.parent.name / table.name + # Xcode may compile .strings to a binary plist, so compare their parsed contents. + source_strings = json.loads(run('plutil', '-convert', 'json', '-o', '-', table, capture=True).stdout) + packed_strings = json.loads(run('plutil', '-convert', 'json', '-o', '-', packed, capture=True).stdout) + if source_strings != packed_strings: + raise RuntimeError(f'Launcher localization staging failed: {packed}') if (app / '_CodeSignature').exists() or (app / 'embedded.mobileprovision').exists(): raise RuntimeError('The smoke-test app must be unsigned') print(f'iOS smoke build passed: {executable}', flush=True) diff --git a/dm/mac/Launcher.swift b/dm/mac/Launcher.swift index a990d05b40..575841436a 100644 --- a/dm/mac/Launcher.swift +++ b/dm/mac/Launcher.swift @@ -60,8 +60,8 @@ enum SaveCheck { let a = NSAlert() a.messageText = SaveSyncReport.unreadableTitle a.informativeText = r?.unreadableMessage(here: "Mac") ?? "" - a.addButton(withTitle: "Play This Mac's Save") - a.addButton(withTitle: "Try Again") + a.addButton(withTitle: launcherString("Play This Mac's Save")) + a.addButton(withTitle: launcherString("Try Again")) a.addButton(withTitle: cancel) a.buttons[2].keyEquivalent = "\u{1b}" switch a.runModal() { @@ -94,10 +94,10 @@ enum SaveCheck { if sem.wait(timeout: .now() + 0.5) == .timedOut { // a cloud file to download: say so, and let the player go on without it app.setActivationPolicy(.regular); app.activate(ignoringOtherApps: true) let a = NSAlert() - a.messageText = "Checking the cloud save…" + a.messageText = launcherString("Checking the cloud save…") a.informativeText = "Waiting for iCloud to deliver this game's save." - a.addButton(withTitle: "Play Without It") - a.addButton(withTitle: "Quit") + a.addButton(withTitle: launcherString("Play Without It")) + a.addButton(withTitle: launcherString("Quit")) box.waiting = true let answer = a.runModal() box.waiting = false @@ -108,7 +108,7 @@ enum SaveCheck { box.lock.lock(); let r = box.report; box.lock.unlock() if case .play(let choice) = SaveSyncReport.step(r) { apply(choice); return true } app.setActivationPolicy(.regular); app.activate(ignoringOtherApps: true) - switch ask(r, cancel: "Quit") { + switch ask(r, cancel: launcherString("Quit")) { case .play(let choice): apply(choice); return true case .retry: continue case .cancel: return false @@ -131,8 +131,8 @@ struct LauncherView: View { HStack(spacing: 12) { Image(nsImage: NSApp.applicationIconImage).resizable().frame(width: 64, height: 64).accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(info("CFBundleName") ?? "Game").font(.title2.weight(.semibold)) - Text("Version \(info("CFBundleShortVersionString") ?? "1.0")").foregroundStyle(.secondary) + Text(info("CFBundleName") ?? launcherString("Game")).font(.title2.weight(.semibold)) + Text(String(format: launcherString("Version %@"), info("CFBundleShortVersionString") ?? "1.0")).foregroundStyle(.secondary) } Spacer() } @@ -141,15 +141,15 @@ struct LauncherView: View { settingSections(basic: true) CloudSavesSection() Section { - DisclosureGroup("Advanced settings", isExpanded: $advanced) {} + DisclosureGroup(launcherString("Advanced settings"), isExpanded: $advanced) {} } if advanced { settingSections(basic: false) Section { - Button("Reset to tested defaults") { m.reset() } + Button(launcherString("Reset to tested defaults")) { m.reset() } .disabled(!m.changed) } footer: { - Text("Brings back all settings this game was tested with.") + Text(launcherString("Brings back all settings this game was tested with.")) } } } @@ -160,15 +160,15 @@ struct LauncherView: View { fit(extraHeight) } HStack { - Toggle("Show this window at launch", isOn: $show).toggleStyle(.checkbox) + Toggle(launcherString("Show this window at launch"), isOn: $show).toggleStyle(.checkbox) Spacer() if checking { ProgressView().controlSize(.small) - Text("Checking the cloud save…").foregroundStyle(.secondary) - Button("Play Without It") { checking = false; SaveCheck.apply("offline"); done(true) } + Text(launcherString("Checking the cloud save…")).foregroundStyle(.secondary) + Button(launcherString("Play Without It")) { checking = false; SaveCheck.apply("offline"); done(true) } } - Button("Quit") { done(false) } - Button("Play") { start() }.keyboardShortcut(.defaultAction).disabled(checking) + Button(launcherString("Quit")) { done(false) } + Button(launcherString("Play")) { start() }.keyboardShortcut(.defaultAction).disabled(checking) } .padding(20) } @@ -184,7 +184,7 @@ struct LauncherView: View { DispatchQueue.main.async { guard checking, check == mine else { return } // the player played without it checking = false - switch SaveCheck.ask(r, cancel: "Cancel") { + switch SaveCheck.ask(r, cancel: launcherString("Cancel")) { case .play(let choice): SaveCheck.apply(choice); done(true) case .retry: start() case .cancel: break // back to the window, nothing chosen @@ -197,7 +197,7 @@ struct LauncherView: View { ForEach(m.sections(basic: basic), id: \.self) { section in let rows = m.rows(in: section, basic: basic) if !rows.isEmpty { - Section(section) { + Section(rows[0].displaySection()) { ForEach(rows) { SettingRow(s: $0, m: m) } } } @@ -217,26 +217,26 @@ struct CloudSavesSection: View { var body: some View { Section { - LabeledContent("Cloud saves") { - Text(fixed.map { "\(Self.shown($0)) (env.txt)" } ?? (folder.isEmpty ? "Off" : Self.shown(folder))) + LabeledContent(launcherString("Cloud saves")) { + Text(fixed.map { String(format: launcherString("%@ (env.txt)"), Self.shown($0)) } ?? (folder.isEmpty ? launcherString("Off") : Self.shown(folder))) .foregroundStyle(.secondary).lineLimit(1).truncationMode(.head) } if fixed == nil { HStack { - Button(folder.isEmpty ? "Choose a folder…" : "Choose a different folder…", action: choose) - if !folder.isEmpty { Button("Turn off") { folder = "" } } + Button(folder.isEmpty ? launcherString("Choose a folder…") : launcherString("Choose a different folder…"), action: choose) + if !folder.isEmpty { Button(launcherString("Turn off")) { folder = "" } } } } } footer: { - Text("Choose the iCloud Drive folder the iPhone app uses for this game. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in the save's -backups folder.") + Text(launcherString("Choose the iCloud Drive folder the iPhone app uses for this game. The save comes in when the game starts and goes out after each save; a save that is replaced is kept in the save's -backups folder.")) } } private func choose() { let panel = NSOpenPanel() panel.canChooseDirectories = true; panel.canChooseFiles = false; panel.allowsMultipleSelection = false; panel.canCreateDirectories = true - panel.prompt = "Use for Cloud Saves" - panel.message = "The folder the iPhone app chose in its launcher's Cloud saves" + panel.prompt = launcherString("Use for Cloud Saves") + panel.message = launcherString("The folder the iPhone app chose in its launcher's Cloud saves") panel.directoryURL = URL(fileURLWithPath: folder.isEmpty ? Self.drive : folder) if panel.runModal() == .OK, let url = panel.url { folder = url.path } } @@ -246,7 +246,7 @@ struct CloudSavesSection: View { /// A path under iCloud Drive as the Finder and the Files app name it: iCloud Drive › Documents › dev › Zelda; any other path as it is static func shown(_ path: String) -> String { guard path == drive || path.hasPrefix(drive + "/") else { return path } - return (["iCloud Drive"] + path.dropFirst(drive.count).split(separator: "/").map(String.init)).joined(separator: " › ") + return ([launcherString("iCloud Drive")] + path.dropFirst(drive.count).split(separator: "/").map(String.init)).joined(separator: " › ") } } @@ -258,10 +258,10 @@ private final class Launcher: NSObject, NSApplicationDelegate, NSWindowDelegate init(_ model: LauncherModel) { self.model = model } func applicationDidFinishLaunching(_ n: Notification) { - let name = info("CFBundleName") ?? "Game" + let name = info("CFBundleName") ?? launcherString("Game") let menu = NSMenu(), item = NSMenuItem(), app = NSMenu() - app.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") - app.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + app.addItem(withTitle: launcherString("Close Window"), action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + app.addItem(withTitle: String(format: launcherString("Quit %@"), name), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") item.submenu = app; menu.addItem(item); NSApp.mainMenu = menu let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 560, height: 660), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) w.title = name; w.isReleasedWhenClosed = false; w.delegate = self diff --git a/dm/mac/pack_app.py b/dm/mac/pack_app.py index f7c8d7c1e4..012bcdded0 100755 --- a/dm/mac/pack_app.py +++ b/dm/mac/pack_app.py @@ -121,7 +121,7 @@ def settings_rules(listed): # DMSettings: what the launcher accepts as each se def info_plist(a, minos, icon): # LSSupportsGameMode off: macOS's Game Mode holds the GPU at its lowest clock while it is busy (docs/reference/frame-rate.md, macOS Game Mode holds the GPU at its lowest clock) p = {'CFBundleName': a.name, 'CFBundleDisplayName': a.name, 'CFBundleIdentifier': a.bundle_id or f'org.nxnative.{a.title_id.lower()}', - 'CFBundleVersion': a.version or '1.0', 'CFBundleShortVersionString': a.version or '1.0', 'CFBundlePackageType': 'APPL', 'CFBundleExecutable': EXE, + 'CFBundleVersion': a.version or '1.0', 'CFBundleShortVersionString': a.version or '1.0', 'CFBundlePackageType': 'APPL', 'CFBundleDevelopmentRegion': 'en', 'CFBundleExecutable': EXE, 'CFBundleInfoDictionaryVersion': '6.0', 'LSMinimumSystemVersion': minos, 'LSArchitecturePriority': ['arm64'], 'NSHighResolutionCapable': True, 'NSBluetoothAlwaysUsageDescription': 'Connect Switch 2 Joy-Con and Pro Controllers over Bluetooth.', 'LSApplicationCategoryType': a.category or 'public.app-category.games', 'LSSupportsGameMode': False, 'DMTitleID': a.title_id.upper(), 'DMRomfsMarker': a.marker or '', 'DMLanguage': a.lang or 'en-US', 'DMSaveSyncChoice': 1} @@ -287,7 +287,7 @@ def pack(a): if not codesign: raise SystemExit('codesign is not available') codesign = Path(codesign).resolve() minos = min_os(a.host) - sources = [Path(__file__).resolve(), HERE / 'launcher.c', HERE.parent / 'host/romfs_image.h', HERE.parent / 'host/save_sync.c', HERE.parent / 'host/save_sync.h', HERE.parent / 'tools/romfs.py'] + swift_sources() + sources = [Path(__file__).resolve(), HERE / 'launcher.c', HERE.parent / 'host/romfs_image.h', HERE.parent / 'host/save_sync.c', HERE.parent / 'host/save_sync.h', HERE.parent / 'tools/romfs.py'] + swift_sources() + sorted((HERE.parent / 'ios/Sources/Resources').rglob('*.strings')) inputs = {'options': vars(a), 'files': [(str(p), p.stat().st_mode, digest(p), os.readlink(p) if p.is_symlink() else None) for p in files + sources], 'trees': [(p, inventory(Path(p))) for p in (a.romfs, a.aoc, a.html, *(str(x[1]) for x in programs), *(str(x[3]) for x in programs)) if p and Path(p).exists()], 'tools': [[str(c), c.stat().st_mode, c.stat().st_size, c.stat().st_mtime_ns, c.stat().st_ctime_ns, run([c, '--version']).strip()] for c in (clang, swiftc)] + [ @@ -323,6 +323,7 @@ def pack(a): if fonts: copy(fonts, dm / 'font', False) if a.env_file: shutil.copyfile(a.env_file, dm / 'env.txt') if a.settings: shutil.copyfile(a.settings, dm / 'launcher.json') + shutil.copytree(HERE.parent / 'ios/Sources/Resources', res, dirs_exist_ok=True) step('shader libraries'); msl_dst = res / 'msl' / (a.msl_name or msl.name); msl_dst.parent.mkdir(exist_ok=True); copy_msl(msl, msls, msl_dst, clone['msl']) if aoc: step('add-on content'); copy([aoc], res / 'aoc', clone['aoc']) if pso: step('pipeline archive'); (res / 'pso').mkdir(); copy(pso, res / 'pso', clone['pso']) diff --git a/dm/tools/tests/test_launcher_settings.py b/dm/tools/tests/test_launcher_settings.py index db5bfb597f..5e9349d437 100644 --- a/dm/tools/tests/test_launcher_settings.py +++ b/dm/tools/tests/test_launcher_settings.py @@ -38,6 +38,21 @@ def test_game_language_row_offers_the_games_own_or_every_language_when_unknown(s s = rows_with_languages([])['DM_LANG'] # no control data: a dump, a NUP without a NACP self.assertEqual(s['options'], [list(l) for l in settings.nacp.LANGUAGES]) self.assertEqual((s['default'], s['note']), ('en-US', settings.UNKNOWN_LANGUAGES)) + def test_localization_keys_preserve_setting_values(self): + ios, mac = build(BOTW, 'nvn'), build(BOTW, 'nvn', 'mac') + for rows in (ios, mac): + s = rows['DM_PRESENT_UPSCALE'] + self.assertIn('label_key', s) + self.assertEqual(s['label_key'], 'setting.DM_PRESENT_UPSCALE.label') + self.assertEqual(s['note_key'], 'setting.DM_PRESENT_UPSCALE.note') + self.assertEqual(s['section_key'], 'section.Game') + self.assertEqual(s['option_keys'], {'off': 'setting.DM_PRESENT_UPSCALE.option.off', + 'metalfx': 'setting.DM_PRESENT_UPSCALE.option.metalfx'}) + self.assertEqual(s['options'], [['off', 'Standard'], ['metalfx', 'MetalFX']]) + self.assertEqual(s['default'], 'off') + self.assertNotIn('note_key', rows['DM_RUMBLE']) # empty notes stay empty + self.assertNotIn('option_keys', rows['DM_RUMBLE']) + self.assertEqual(mac['DM_FULLSCREEN']['option_keys']['0'], 'setting.DM_FULLSCREEN.option.0') def test_upscaling_is_opt_in_for_every_graphics_api(self): for api, gid in [('nvn', BOTW), ('vulkan', '0100535012974000'), ('gl', '010014E00DB56000')]: @@ -140,6 +155,21 @@ def test_written_lists_follow_the_schema(self): patch.object(settings.nacp, 'languages', return_value=['en-US']), patch.dict(os.environ, {'GFXAPI': api, **({'SETTINGS_PLATFORM': platform} if platform else {})}): self.assertEqual(conforms(settings.build(gid, tmp, Path(tmp) / 'control.nacp'), schema, schema), []) + def test_localization_keys_follow_conditional_options(self): + for gid, platform, offered in [('04007EF01F8AE000', 'mac', True), ('04007EF01F8AE000', 'ios', False), ('010056B015FE8000', 'mac', False)]: + row = build(gid, 'nvn', platform)['DM_FPS_TARGET'] + self.assertEqual(row['option_keys'], {v: f'setting.DM_FPS_TARGET.option.{v}' for v, _ in row['options']}) + self.assertEqual('120' in row['option_keys'], offered) + + def test_localization_fields_are_optional_and_keep_schema_1(self): + schema = json.loads((ROOT / 'docs/launcher.schema.json').read_text(encoding='utf-8')) + row = build(BOTW, 'nvn')['DM_OPMODE'] + legacy = {k: v for k, v in row.items() if k not in ('label_key', 'note_key', 'section_key', 'option_keys')} + for setting in (row, legacy): + self.assertEqual(conforms({'schema': 1, 'game': BOTW, 'settings': [setting]}, schema, schema), []) + for bad in ({'label_key': 1}, {'option_keys': {'0': 1}}): + self.assertNotEqual(conforms({'settings': [{**row, **bad}], 'game': BOTW}, schema, schema), []) + def test_schema_1_takes_new_sections_but_not_new_kinds(self): """A section is a heading a reader draws by its name, so a new one stays schema 1; a new kind would not""" schema = json.loads((ROOT / 'docs/launcher.schema.json').read_text(encoding='utf-8')) @@ -186,7 +216,7 @@ def test_table_format_is_strict(self): def conforms(value, node, root, at='$'): - """The JSON Schema keywords launcher.schema.json uses (type, required, properties, items, enum, const, pattern, $ref, allOf with + """The JSON Schema keywords launcher.schema.json uses (type, required, properties, items, enum, const, pattern, additionalProperties, $ref, allOf with if / then), as a list of problems: enough to hold the written lists to the contract without a jsonschema package""" if '$ref' in node: return conforms(value, root['$defs'][node['$ref'].rsplit('/', 1)[1]], root, at) problems = [] @@ -199,6 +229,7 @@ def conforms(value, node, root, at='$'): problems += [f'{at}: no {k}' for k in node.get('required', []) if k not in value] for k, v in value.items(): if k in node.get('properties', {}): problems += conforms(v, node['properties'][k], root, f'{at}.{k}') + elif isinstance(node.get('additionalProperties'), dict): problems += conforms(v, node['additionalProperties'], root, f'{at}.{k}') for rule in node.get('allOf', []): if not conforms(value, rule['if'], root, at): problems += conforms(value, rule['then'], root, at) if isinstance(value, list) and 'items' in node: diff --git a/dm/tools/tests/test_nsp_mac_app.py b/dm/tools/tests/test_nsp_mac_app.py index f805fcec17..63715eaedd 100644 --- a/dm/tools/tests/test_nsp_mac_app.py +++ b/dm/tools/tests/test_nsp_mac_app.py @@ -110,7 +110,7 @@ def setUp(self): self.tmp = Path(tempfile.mkdtemp(prefix='pack-reuse-', dir=TEST_ROOT)).resolve() self.addCleanup(shutil.rmtree, self.tmp) t = self.tmp - for d in ('patched', 'romfs/empty', 'msl', 'profile', 'font', 'aoc/1', 'pso', 'bin', 'sdk', 'source', 'host', 'tools', 'ios/Sources'): + for d in ('patched', 'romfs/empty', 'msl', 'profile', 'font', 'aoc/1', 'pso', 'bin', 'sdk', 'source', 'host', 'tools', 'ios/Sources/Resources/en.lproj'): (t / d).mkdir(parents=True) files = { 'dmhost': b'host', 'patched/main.patched.bin': b'code', 'patched/main.fixups.tsv': b'fixups', @@ -121,6 +121,7 @@ def setUp(self): 'pso/gpu-prewarm-00.mtl4a': b'bases', 'pso/gpu-prewarm-00.keys': b'DMPSOIX1', 'pso/prewarm.tsv': b'stamp', # --prebake's files and their index 'env.txt': b'DM_TEST=1', 'split.tsv': b'split', 'icon.jpg': b'jpeg', 'source/launcher.c': b'launcher', 'host/romfs_image.h': b'validator', 'host/save_sync.c': b'sync', 'host/save_sync.h': b'sync api', 'tools/romfs.py': b'reader', + 'ios/Sources/Resources/en.lproj/Launcher.strings': b'"Play" = "Play";', 'source/Launcher.swift': b'window', 'ios/Sources/LauncherSettings.swift': b'model', 'ios/Sources/LauncherRow.swift': b'rows', 'ios/Sources/SaveSyncReport.swift': b'report', } for n in pack_app.TABLES: files['profile/' + n] = n.encode() @@ -195,6 +196,23 @@ def test_retargeted_input_symlink_repacks(self): self.assertEqual(os.readlink(packed), 'right.metallib') self.assert_reused() + def test_localization_tables_are_packed_and_invalidate_reuse(self): + self.pack(); self.assert_reused() + source = self.tmp / 'ios/Sources/Resources' + resources = Path(self.args.out) / 'Contents/Resources' + self.assertEqual((resources / 'en.lproj/Launcher.strings').read_bytes(), (source / 'en.lproj/Launcher.strings').read_bytes()) + translated = source / 'fr.lproj/Launcher.strings' + translated.parent.mkdir() + translated.write_text('"Play" = "Translated play";') + self.assert_repacked() + self.assertEqual((resources / 'fr.lproj/Launcher.strings').read_bytes(), translated.read_bytes()) + translated.write_text('"Play" = "Updated play";') + self.assert_repacked() + self.assertEqual((resources / 'fr.lproj/Launcher.strings').read_bytes(), translated.read_bytes()) + translated.unlink() + self.assert_repacked() + self.assertFalse((resources / 'fr.lproj/Launcher.strings').exists()) + def test_unchanged_reuses_before_partial_clone_compile_and_sign(self): self.pack() partial = Path(self.args.out + '.partial'); partial.mkdir(); (partial / 'leave').write_text('untouched') diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 5ad31fcfd7..aa2f2b052d 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -20,6 +20,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - `vk_shaders.py` stops with `OSError: [Errno 7] Argument list too long: 'xcrun'` after the Metal compiles, in the `metallib` link. → [The Vulkan shader step fails with `Argument list too long: 'xcrun'`](../reference/packing.md#the-vulkan-shader-step-fails-with-argument-list-too-long-xcrun) - the shader split stops with `the split shader tables differ from game profile 's`, on the game version the profile registers. → [The split shader tables differ from a game profile's](../reference/packing.md#the-split-shader-tables-differ-from-a-game-profiles) - `dmsh_build.py` stops with `[RED] holds no record of shader , which the shader map names: the package and the map come from different shader_split.py runs`, with `(its records carry no keys: it was split before they did)` for a package from before chain `3df44a5a4e0c`. Before that chain: wrong shaders wherever the game drew, such as a black screen with sound. → [The shader package and the shader map come from different splits](../reference/packing.md#the-shader-package-and-the-shader-map-come-from-different-splits) +- the iOS and Mac launchers show English settings and buttons regardless of the app's language, and contributors must edit source code to translate them. → [Launcher buttons and settings cannot be translated](../reference/packing.md#launcher-buttons-and-settings-cannot-be-translated) - AltServer stops installing an IPA with "The file “b-.img” couldn’t be saved in the folder “msl”", although the RomFS stays out of it (`--romfs separate`). Other install tools install the same IPA. → [AltServer cannot install a file of 2 GiB or more](../reference/packing.md#altserver-cannot-install-a-file-of-2-gib-or-more) - packing succeeds, but `unzip -t` or Python's `ZipFile.testzip()` reports a bad CRC for a large deflated file. → [A streamed file changes size and the IPA fails its CRC check](../reference/packing.md#a-streamed-file-changes-size-and-the-ipa-fails-its-crc-check) - `devicectl` copying a large RomFS folder fails with POSIX error 24 (`EMFILE`) or spends most of its time on small files; deleting an old folder during iOS startup hits the launch watchdog. A push after changing extracted files can also keep sending old assets if an image is reused solely because it exists. → [RomFS folder transfers fail or leave stale game files](../reference/packing.md#romfs-folder-transfers-fail-or-leave-stale-game-files) diff --git a/docs/development.md b/docs/development.md index 44c96be81d..afe067520c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -593,9 +593,12 @@ The bundle holds what the IPA holds, with the Mac's shader libraries: Resources/aoc// the DLC, when the folder has some Resources/pso/ an NVN game's pipeline archive made offline for this Mac's GPU (.mtl4a, prebuilt.tsv; --gpu none leaves it out) Resources/dm/ the rewritten modules and their rewriter tables, nro.tsv, the profile's shader_map.tsv, pso_manifest.tsv, c1_full.tsv, cb_extent.tsv, shader_pairs.tsv and pso_pairs.tsv, font/, env.txt (--env-file) + Resources/.lproj/Launcher.strings shared launcher translations Resources/AppIcon.icns ``` +The built-in Mac and iOS launchers share `dm/ios/Sources/Resources/.lproj/Launcher.strings`. Prepared Xcode projects and unsigned iOS builds include these tables; the Mac packer copies them into `Contents/Resources`. Adding, changing or removing a string table invalidates Mac bundle reuse. The launcher follows the app's preferred system language, independently of the game language (`DM_LANG`), with English fallbacks. See [Translating the built-in launchers](frontends.md#translating-the-built-in-launchers) for the resource layout and stable setting-key contract. + The shader pairs are what the pipeline prebuild and the hybrid pipelines read ([Hybrid pipelines](#hybrid-pipelines)), as in the IPA; a profile without `pso_pairs.tsv` gets the shader split's copy (`--pso-pairs`). An app without them builds no base: [Shader-pair tables in a Mac app](reference/shaders.md#shader-pair-tables-in-a-mac-app). An NVN game's app also carries its pipeline archive made offline ([The pipeline archive made offline](#the-pipeline-archive-made-offline)), built by `pso_offline.py --platform macos` for this Mac's GPU (`MTLDevice.architecture`, e.g. `applegpu_g14s` for an M2 Max; `--gpu` names others, `none` builds none) into `_build/pso/mac` and made again only when its inputs change, with a compute pipeline for each compute shader beside the bases. The launcher hands `Resources/pso` to the host as `DM_PSO_BUNDLE`, as the iOS app's `main.m` does: the first start builds no base (`pipeline archive bundled with the app: holds the bases of every shader pair (16 of 16 probed bases found …)`), and the bases (about 615 MB for Breath of the Wild) stay in the app instead of the player's `~/Library/Application Support`. On a Mac with another GPU, or after a macOS update that changed the GPU compiler, the app builds them there as before. `PSO_GPUS` is shared with the IPA, so a value that does not name this Mac's GPU (`g18p`, an iPhone's) still builds what it names, and the console says that this Mac's app will build its own bases. When every eligible pair declares function constants, `pso_offline.py` makes no archive and the app is packed without one. diff --git a/docs/frontends.md b/docs/frontends.md index 6f8fff0d47..ea51c86005 100644 --- a/docs/frontends.md +++ b/docs/frontends.md @@ -46,9 +46,23 @@ A built app carries the settings its game supports, as the iPhone app's settings - `base` (optional, render scale only): `{"docked": [w, h], "handheld": [w, h]}`, the 3D views' size that the scale multiplies, so a front end can show 0.75 as 1200×675 docked. It comes from the game's profile (`render_base`), and only some games have it. - `level` (optional): `basic` marks a setting to show first, for a front end that shows a short list and puts the rest behind a second step. Without it, the front end decides. - `needs` (optional): `=` makes the row a sub-setting of the row that sets ``, drawn only while that row has that value (Rumble strength only while rumble is on). A row naming a variable the list does not have stays drawn. +- `label_key`, `note_key`, `section_key` (optional): keys in the app's `Launcher.strings` table. The existing `label`, `note` and `section` text is the English fallback. Group rows by the original `section`, even when two translated headings are identical. +- `option_keys` (optional): a map from a choice's raw value to its `Launcher.strings` key. Keep each `options` pair as `[value, English label]`, and fall back to that label if the key or translation is absent. - An app without the file has no settings to offer: start it as it is. -**Compatibility.** [launcher.schema.json](launcher.schema.json) describes the file as a JSON Schema, so a front end can check it and generate its types from it. `schema` is 1, and schema 1 grows only by optional fields: a reader ignores fields it does not know, draws sections it does not know, and a field never changes its meaning. A new `kind` would be schema 2, since a reader cannot draw a kind it does not know. A list written before the field existed has no `schema` and is 1. In `settings.tsv`, the optional fields come from the last column, `meta` (`key=value` pairs separated by `;`); a new key is added in `settings.py`'s `META`, here and in the schema together, and `test_launcher_settings.py` fails when the three disagree. +**Compatibility.** [launcher.schema.json](launcher.schema.json) describes the file as a JSON Schema, so a front end can check it and generate its types from it. `schema` is 1, and schema 1 grows only by optional fields: a reader ignores fields it does not know, draws sections it does not know, and a field never changes its meaning. A new `kind` would be schema 2, since a reader cannot draw a kind it does not know. A list written before the field existed has no `schema` and is 1. Localization keys are derived from the setting's environment variable, section and choice values. In `settings.tsv`, the other optional fields come from the last column, `meta` (`key=value` pairs separated by `;`); a new key is added in `settings.py`'s `META`, here and in the schema together, and `test_launcher_settings.py` fails when the three disagree. + +### Translating the built-in launchers + +Both launchers use the same Apple string table, [dm/ios/Sources/Resources/en.lproj/Launcher.strings](../dm/ios/Sources/Resources/en.lproj/Launcher.strings). To add a language, copy it to `.lproj/Launcher.strings` beside `en.lproj` (for example `zh-Hans.lproj`), and translate the values while keeping the keys. No Swift or `settings.tsv` changes are needed. Prepared and command-line iOS builds include these resources, and the Mac packer copies them into `Contents/Resources`. Rebuild the app to include a new translation; changing a table invalidates the Mac packer's reuse key. + +The launcher follows the app's preferred system language. **Game language** (`DM_LANG`) still selects only the language of the game. The English strings stay available when a locale, key or optional JSON field is missing; older front ends can continue rendering the existing English text. Game names, version values, folder names and environment values are not translation keys. + +- Launcher buttons, status text and menus, including the cloud-save retry and pending-check controls, use their English text as keys. Preserve the `%@` placeholder in formatted text such as `Version %@` and `Quit %@`; a translator can reposition it with `%1$@`. +- Settings use stable keys: `setting..label`, `setting..note` (only for a nonempty note), `section.
`, and `setting..option.`. For example, `setting.DM_OPMODE.option.0` translates **Handheld**; the stored value remains `"0"`. Adding a setting also adds its English entries to the shared table; the catalog test checks them against `settings.tsv` and the language list. +- Third-party front ends can read the same tables or map these keys to their own localization system. They must keep raw option values, section identities, defaults and persistence keys unchanged. + +No translated language is claimed complete until its table is contributed and reviewed. ## Starting a game diff --git a/docs/launcher.schema.json b/docs/launcher.schema.json index 4bb97df3c0..01c84fdad0 100644 --- a/docs/launcher.schema.json +++ b/docs/launcher.schema.json @@ -21,6 +21,10 @@ "section": {"description": "The heading it goes under (Game, Controls and Performance today); sections and rows come in the order to show them. A reader draws a section it does not know like the others, under its name.", "type": "string"}, "label": {"type": "string"}, "note": {"description": "The line under the label; empty for most settings.", "type": "string"}, + "label_key": {"description": "Optional Launcher.strings key; label is the English fallback.", "type": "string"}, + "note_key": {"description": "Optional Launcher.strings key; note is the English fallback.", "type": "string"}, + "section_key": {"description": "Optional Launcher.strings key; section is the English fallback and grouping identity.", "type": "string"}, + "option_keys": {"description": "Optional map from a choice's raw value to its Launcher.strings key; the option label is the English fallback.", "type": "object", "additionalProperties": {"type": "string"}}, "default": {"description": "The tested default for this app's platform, with profile defaults resolved.", "type": "string"}, "options": {"description": "choice: [value, label] pairs.", "type": "array", "items": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 2}}, "min": {"description": "range: the lowest value.", "type": "number"}, diff --git a/docs/reference/packing.md b/docs/reference/packing.md index 49095a5bd2..8a17e44a2b 100644 --- a/docs/reference/packing.md +++ b/docs/reference/packing.md @@ -118,6 +118,15 @@ - **Fix:** general, in the package format. `shader_split.py` writes each record's key (its 8 bytes) at record +0x08, in header bytes that were zero; `dmsh_build.py` `record_index` indexes a package by those keys and `rec_of` finds a row's record by its key, so a row's index no longer matters and a package that does not hold a row's shader stops the build before the first job. The control section is searched from +0x38, after the header. The map keeps its index column, so the profiles' tables did not change. A package from before the change carries no keys: the NSP script splits again by itself (its key covers `shader_split.py`), while a tool that hands `dmsh_build.py` an older package, such as `regress.py`'s use of a game's `_build/shader/pkg`, needs that build run again first. `test_shader_package_keys` covers both sides. - **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md) ([issue #1841](https://github.com/seedds/nxnative/issues/1841), diagnosed by its reporter). A build through NX2Apple 1.3.0 kept `ps_lib.bin` from an older split (4,711 records) beside a map from a split after 04df08d4 (4,707 rows), so the present pass's pixel shader `29e3c9dd92b8a571` was translated from a terrain shader's record and drew black. +## Launcher text + +### Launcher buttons and settings cannot be translated + +- **Symptom:** the iOS and Mac launchers show English settings and buttons regardless of the app's language, and contributors must edit source code to translate them. +- **Cause:** UI literals have no bundled string table; dynamic labels from `launcher.json` are rendered verbatim and have no translation keys. +- **Fix:** both launchers look up a shared `Launcher.strings` table. The settings generator adds optional stable keys while retaining English fallbacks and the original values. Both app packers include the locale resources. The translation and compatibility contract is in [Translating the built-in launchers](../frontends.md#translating-the-built-in-launchers). +- **Found on:** [issue #775](https://github.com/seedds/nxnative/issues/775), covering both launchers. + ## IPA archives ### AltServer cannot install a file of 2 GiB or more