diff --git a/config.example.toml b/config.example.toml index bd34eba8..0140e584 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,7 @@ updates = ['C:\Games\TotK\update-1.4.3.nsp'] keys = 'C:\Switch\prod.keys' # Optional: a firmware NCA directory (an unpacked firmware, or an emulator's installed registered/ directory). # tools/extract_fonts.py takes the system shared fonts from it; the game needs them only with Chinese or Korean text. +# The installer asks for the same folder (--firmware) when the language needs those fonts. # firmware = 'C:\Switch\firmware' [work] diff --git a/docs/installer.md b/docs/installer.md index 304e239c..54d2fe6e 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -11,6 +11,8 @@ holds only the game; the installer and its tools stay outside it. 2. Picks: - the base game (XCI or NSP) and the 1.2.1 or 1.4.3 update (NSP); - their `prod.keys`; + - optionally the firmware folder (`system/Contents/registered` of an unpacked firmware or an emulator's install), + needed for Chinese, Korean and Traditional Chinese text; - the game language and the install folder. 3. Waits for the setup, one time. The window shows each step and its progress. 4. Plays `totk.exe` from the install folder. The installer and the source NSP or XCI can be deleted. @@ -22,6 +24,7 @@ The install folder (`main.c` `portable_paths()` reads this layout; it stays port totk.exe, SDL3.dll, libopus-0.dll, totk_xlate.exe totk.ini language; the other keys as commented defaults (totk.ini.example) exefs/, romfs/ extracted from the player's files + shared-fonts/ the Switch system fonts, with Chinese, Korean or Traditional Chinese text profile/ saves logs/ totk.log of the last run, totk.1.log to totk.4.log of the runs before it shader and pipeline caches prepared by the setup; shader-cache/shader-cache.pack holds the translations @@ -31,8 +34,8 @@ The install folder (`main.c` `portable_paths()` reads this layout; it stays port ## Updating the port The player runs a newer installer on the same folder. It reads `totk-install.json`, keeps `romfs/`, `exefs/`, -`profile/` and `totk.ini` after checking their hashes, rebuilds `totk.exe` and re-prepares the shaders. A newer -release that changes nothing game-derived skips the rebuild. +`shared-fonts/`, `profile/` and `totk.ini` after checking their hashes, rebuilds `totk.exe` and re-prepares the +shaders. A newer release that changes nothing game-derived skips the rebuild. Given an update file other than the recorded one (1.2.1, then 1.4.3), the installer extracts and builds again from it and keeps `profile/`. Saves carry over both ways: 1.4.3 converts a 1.2.1 save on load, and 1.2.1 loads the saves 1.4.3 wrote. @@ -43,19 +46,22 @@ keeps `profile/`. Saves carry over both ways: 1.4.3 converts a 1.2.1 save on loa `tools/extract.py`), the keys are new enough for the update, enough space is free on the target disk (about 36 GiB at the peak of a first install, 20 GiB after it), the CPU has AVX2 (`-march=x86-64-v3`), a Vulkan 1.3 GPU exists. Each failure is one clear message. -2. **Extract** ExeFS and RomFS into the install folder and verify the hashes. -3. **Lift** the ExeFS to C (`recomp/gen.py`, `recomp/patches-v121.txt` or `patches-v143.txt`) into a temp folder next +2. **Fonts** with Chinese, Korean or Traditional Chinese text and a firmware folder: extract the Switch system fonts + from the firmware into `shared-fonts/` (MacNativeGames' `extract_shared_fonts.py`, which verifies every NCA layer + and BFTTF header). Without a firmware folder the setup warns and goes on; the game then lacks those glyphs. +3. **Extract** ExeFS and RomFS into the install folder and verify the hashes. +4. **Lift** the ExeFS to C (`recomp/gen.py`, `recomp/patches-v121.txt` or `patches-v143.txt`) into a temp folder next to the installer. -4. **Compile** the generated C, `modules.c`, `romfs.c`, `patches.c` and `ov_zstd.c` (they bake the per-install layout, RomFS +5. **Compile** the generated C, `modules.c`, `romfs.c`, `patches.c` and `ov_zstd.c` (they bake the per-install layout, RomFS hashes and the game version's patch page) and link them with the prebuilt runtime library into `totk.exe` (`build_game.py --runtime-lib`; about 3 min on 24 cores). -5. **Prepare** the RomFS patches and the shaders (`tools/make_romfs_patches.py`, `tools/prepare_shaders.py`, +6. **Prepare** the RomFS patches and the shaders (`tools/make_romfs_patches.py`, `tools/prepare_shaders.py`, `totk.exe --prepare-shaders`) in the install folder, then fold the shader cache's loose files (four per shader, about 117,000) into `shader-cache/shader-cache.pack` (`tools/pack_shader_cache.py`). totk.exe reads the pack first and writes the translations it makes in play as loose files; running the tool again with the game closed folds those in. The patches are written twice: `romfs-patches` and `romfs-patches-xbox` (`--xbox-prompts`, the A/B and X/Y prompts swapped for `pad_layout = xbox_prompts`). -6. **Clean up** the temp folder (generated C and objects, about 1.3 GB). +7. **Clean up** the temp folder (generated C and objects, about 1.3 GB). A cancelled or failed setup resumes from the last finished step (`totk-install.json`). @@ -64,10 +70,13 @@ A cancelled or failed setup resumes from the last finished step (`totk-install.j `installer/` (stdlib only; `installer/layout.py` maps the release folder and, in a repository, the dev toolchain): ``` -python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--root DIR] [--temp DIR] [--verify] +python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--firmware DIR] [--root DIR] [--temp DIR] [--verify] ``` - On a folder with `totk-install.json` only `--dest` is needed; the game files only when the extraction must run again. +- `--firmware DIR` is the folder holding the firmware NCAs: an unpacked firmware, or the emulators' installed + `Contents/registered` (the parent `Contents` folder works too). It is read only with Chinese, Korean or Traditional + Chinese text, and only when `shared-fonts/` is not already in the install folder. - stdout: one JSON event per line, types `start`, `plan`, `step` (started, done, skipped), `progress` (step, phase, done/total/unit), `space` (bytes needed and free on the install disk and the temp disk), `warning`, `error`, `end` (`installer/events.py`). Messages are ids with parameters; `installer/text/en.json` holds their English text. The human-readable log goes to `/logs/`. diff --git a/installer/__init__.py b/installer/__init__.py index 7c523f73..25092d16 100644 --- a/installer/__init__.py +++ b/installer/__init__.py @@ -1,9 +1,10 @@ """The TotK installer: builds a playable folder from the player's own game files (docs/installer.md). - python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--root DIR] [--temp DIR] + python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--firmware DIR] [--root DIR] + [--temp DIR] Modules: layout (where the bundled tools are, release or repository), events (JSON progress on stdout and the log -file), state (totk-install.json), runner (which steps run: resume and update), steps (the six steps), ini (totk.ini), +file), state (totk-install.json), runner (which steps run: resume and update), steps (the seven steps), ini (totk.ini), cli (the command line). text/en.json holds the English text of every message id the events carry. Standard library only, apart from what the extraction tools import (cryptography). Runs on Python 3.14. diff --git a/installer/cli.py b/installer/cli.py index 538f49d1..17e6da64 100644 --- a/installer/cli.py +++ b/installer/cli.py @@ -1,10 +1,12 @@ -"""Command line: python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--root DIR] - [--temp DIR] [--verify] +"""Command line: python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR [--firmware DIR] + [--root DIR] [--temp DIR] [--verify] -A first install needs every option. On a folder with totk-install.json (resume after a cancel or failure, or update -with a newer release) only --dest is needed: --lang defaults to the recorded language, and the game files are needed -only when the extraction has to run again. Prints JSON events on stdout (events.py). Exit code: 0 done, 1 failed, -2 bad command line, 3 cancelled (Ctrl+C or Ctrl+Break). +A first install needs every option. --firmware names the Switch firmware folder (an unpacked firmware or an emulator's +Contents/registered); the setup extracts the system shared fonts from it when the language is Chinese, Korean or +Traditional Chinese. On a folder with totk-install.json (resume after a cancel or failure, or update with a newer +release) only --dest is needed: --lang defaults to the recorded language, and the game files are needed only when the +extraction has to run again. Prints JSON events on stdout (events.py). Exit code: 0 done, 1 failed, 2 bad command +line, 3 cancelled (Ctrl+C or Ctrl+Break). """ import argparse import hashlib @@ -48,6 +50,8 @@ def main(argv=None): ap.add_argument('--base', type=Path, help='base game, XCI or NSP') ap.add_argument('--update', type=Path, help='the update NSP: 1.1.1, 1.2.1 or 1.4.3') ap.add_argument('--keys', type=Path, help='prod.keys') + ap.add_argument('--firmware', type=Path, help='Switch firmware folder (Contents/registered) for the shared fonts ' + 'of Chinese, Korean and Traditional Chinese text') ap.add_argument('--lang', help='game language, a Switch language code (en-US, en-GB, ja, de, ru, ...)') ap.add_argument('--dest', type=Path, required=True, help='install folder') ap.add_argument('--root', type=Path, help='release folder or repository (default: the folder above installer/)') diff --git a/installer/gui.py b/installer/gui.py index cd94fcde..375d2941 100644 --- a/installer/gui.py +++ b/installer/gui.py @@ -1,8 +1,9 @@ """The installer window: python -m installer.gui -A front end over the CLI (docs/installer.md): it collects the game files, the keys file path, the game language and the -install folder, runs `python -m installer ...` as a subprocess and shows its JSON events. Decisions live in guilogic.py; -this file only draws. The window text is in installer/text/.json under "ui.*" and "launcher.*" ids. +A front end over the CLI (docs/installer.md): it collects the game files, the keys file path, the optional firmware +folder, the game language and the install folder, runs `python -m installer ...` as a subprocess and shows its JSON +events. Decisions live in guilogic.py; this file only draws. The window text is in installer/text/.json under +"ui.*" and "launcher.*" ids. --selftest [--shots DIR] build every screen in both languages and close by itself (PNG per screen with --shots) --lang en|ru window language (default: the Windows display language) @@ -51,7 +52,7 @@ class App: self.run_args = run_args or {} # extra CLI options for tests: temp, verify self.log_entries = [] # events and raw lines of the run, rendered by render_log self.bound = [] # (widget, option, id, params) retranslated on a language switch - self.vars = {n: tk.StringVar() for n in ('base', 'update', 'keys', 'dest')} + self.vars = {n: tk.StringVar() for n in ('base', 'update', 'keys', 'firmware', 'dest')} self.vars['lang'] = tk.StringVar() self.lang_code = default_game_language(ui_lang) self.screen = None @@ -197,6 +198,10 @@ class App: b.grid(row=i, column=2, padx=(8, 0)) self.form_rows[name] = lab i = len(rows) + self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=4) + ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=4) + self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(8, 0)) + i += 1 self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=4) self.lang_box = ttk.Combobox(grid, textvariable=self.vars['lang'], state='readonly', width=30) self.lang_box.grid(row=i, column=1, sticky='w', pady=4) @@ -206,6 +211,7 @@ class App: self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=4) ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=4) self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(8, 0)) + self.label(f, 'ui.firmware_help', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0)) self.update_hint = ttk.Label(f, wraplength=620, justify='left') self.update_hint.pack(anchor='w', pady=(8, 0)) self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=620, justify='left') @@ -266,7 +272,7 @@ class App: def form(self): return Form(base=self.vars['base'].get(), update=self.vars['update'].get(), keys=self.vars['keys'].get(), - lang=self.selected_lang(), dest=self.vars['dest'].get()) + firmware=self.vars['firmware'].get(), lang=self.selected_lang(), dest=self.vars['dest'].get()) def refresh_form(self): form = self.form() @@ -290,6 +296,11 @@ class App: if path: self.vars[name].set(os.path.normpath(path)) + def pick_firmware(self): + path = filedialog.askdirectory(title=self.cat('ui.pick_firmware'), mustexist=True) + if path: + self.vars['firmware'].set(os.path.normpath(path)) + def pick_dest(self): path = filedialog.askdirectory(title=self.cat('ui.pick_dest'), mustexist=False) if path: @@ -306,7 +317,7 @@ class App: return self.form_error.configure(text='') command = form.command(self.layout.python, root=self.layout.root if self.layout.dev else None, - temp=self.run_args.get('temp'), verify=self.run_args.get('verify', False)) + temp=self.run_args.get('temp'), verify_hashes=self.run_args.get('verify', False)) self.begin(command, self.layout.root) def begin(self, command, cwd): @@ -495,10 +506,10 @@ class App: # ---- self test --------------------------------------------------------------------------------------------------- SAMPLE_START = {'type': 'start', 'release': 'selftest', 'mode': 'install', 'dest': 'Z:/selftest/TotK', 'log': 'D:\\log.txt', - 'steps': ['check', 'extract', 'lift', 'compile', 'prepare', 'cleanup']} + 'steps': ['check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'cleanup']} SAMPLE_RUN = [ SAMPLE_START, - {'type': 'plan', 'run': ['check', 'extract'], 'skip': {}}, + {'type': 'plan', 'run': ['check', 'fonts', 'extract'], 'skip': {}}, {'type': 'step', 'step': 'check', 'state': 'started'}, {'type': 'progress', 'step': 'check', 'phase': 'inputs'}, {'type': 'space', 'dest': 'Z:/selftest/TotK', 'need': 36_400_000_000, 'free': 210_000_000_000, 'temp': 'C:\\t', @@ -511,12 +522,14 @@ SAMPLE_RUN = [ ] -SAMPLE_OK = [SAMPLE_START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'lift': 'current', 'compile': 'current', - 'prepare': 'current', 'cleanup': 'not_needed'}}, +SAMPLE_OK = [SAMPLE_START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'fonts': 'current', 'lift': 'current', + 'compile': 'current', 'prepare': 'current', + 'cleanup': 'not_needed'}}, {'type': 'step', 'step': 'check', 'state': 'started'}, {'type': 'step', 'step': 'check', 'state': 'done', 'seconds': 1.0}, {'type': 'step', 'step': 'extract', 'state': 'started'}, {'type': 'step', 'step': 'extract', 'state': 'done', 'seconds': 1.0}, + {'type': 'step', 'step': 'fonts', 'state': 'skipped', 'reason': 'current'}, {'type': 'step', 'step': 'lift', 'state': 'skipped', 'reason': 'current'}, {'type': 'step', 'step': 'compile', 'state': 'skipped', 'reason': 'current'}, {'type': 'step', 'step': 'prepare', 'state': 'skipped', 'reason': 'current'}, diff --git a/installer/guilogic.py b/installer/guilogic.py index 8d6c4e91..3343c187 100644 --- a/installer/guilogic.py +++ b/installer/guilogic.py @@ -25,7 +25,7 @@ from .events import render TEXT = Path(__file__).resolve().parent / 'text' UI_LANGUAGES = ('en', 'ru') -STEP_ORDER = ('check', 'extract', 'lift', 'compile', 'prepare', 'cleanup') +STEP_ORDER = ('check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'cleanup') # message parameter -> catalog id prefix of the names that replace its raw values PARAM_NAMES = {'lang': 'lang.', 'known': 'lang.', 'step': 'step.', 'input': 'param.input.'} LANG_RU = 0x19 # primary language id of Russian in a Windows LANGID @@ -111,6 +111,7 @@ class Form: base: str = '' update: str = '' keys: str = '' + firmware: str = '' lang: str = '' dest: str = '' @@ -147,19 +148,21 @@ class Form: value = getattr(self, name).strip() if value and not Path(value).is_file(): found.append(f'ui.missing_{name}') + if self.firmware.strip() and not Path(self.firmware.strip()).is_dir(): + found.append('ui.missing_firmware') return found - def command(self, python, root=None, temp=None, verify=False): + def command(self, python, root=None, temp=None, verify_hashes=False): cmd = [str(python), '-m', 'installer'] for flag, value in (('--base', self.base), ('--update', self.update), ('--keys', self.keys), - ('--lang', self.lang), ('--dest', self.dest)): + ('--firmware', self.firmware), ('--lang', self.lang), ('--dest', self.dest)): if value.strip(): cmd += [flag, value.strip()] if root: cmd += ['--root', str(root)] if temp: cmd += ['--temp', str(temp)] - if verify: + if verify_hashes: cmd.append('--verify') return cmd diff --git a/installer/ini.py b/installer/ini.py index 13147862..5cad6f00 100644 --- a/installer/ini.py +++ b/installer/ini.py @@ -14,7 +14,8 @@ LANGUAGES = ('ja', 'en-US', 'en-GB', 'fr', 'fr-CA', 'de', 'it', 'es', 'es-419', 'zh-Hans', 'zh-Hant') # Older installer builds wrote these two codes; they are read as the canonical ones and rewritten on the next install. ALIASES = {'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant'} -# Their text falls back to the Switch shared fonts (runtime/ov_system.c), which the installer does not extract. +# Their text falls back to the Switch shared fonts (runtime/ov_system.c), which the fonts step extracts from the +# firmware folder when the player gives one. NEED_SHARED_FONTS = ('ko', 'zh-Hans', 'zh-Hant') ACTIVE = re.compile(r'^(\s*)(?:totk_)?lang(\s*=\s*)(.*?)(\s*)$', re.IGNORECASE) diff --git a/installer/layout.py b/installer/layout.py index 7f31443e..4dfcd64c 100644 --- a/installer/layout.py +++ b/installer/layout.py @@ -81,6 +81,7 @@ class Layout: """Paths the steps need that do not exist.""" need = [self.python, self.tools / 'extract.py', self.tools / 'build_game.py', self.tools / 'prepare_shaders.py', self.tools / 'pack_shader_cache.py', self.tools / 'make_romfs_patches.py', self.tools / 'ultrawide_layout.py', + self.tools / 'vendor' / 'macnativegames' / 'extract_shared_fonts.py', self.recomp / 'gen.py', self.clang, *((self.deps / 'include', self.deps / 'lib') if self.deps else ()), self.runtime, self.runtime_lib, self.ninja, self.hactool, diff --git a/installer/steps.py b/installer/steps.py index 2a165641..74d00531 100644 --- a/installer/steps.py +++ b/installer/steps.py @@ -1,13 +1,14 @@ -"""The six setup steps of docs/installer.md: check, extract, lift, compile, prepare, cleanup. +"""The seven setup steps of docs/installer.md: check, fonts, extract, lift, compile, prepare, cleanup. -Extraction runs in this process through tools/extract.py (the package's sibling tools/, which ships with it); lift, -compile and prepare run the bundled Python on recomp/gen.py, tools/build_game.py, tools/make_romfs_patches.py and -tools/prepare_shaders.py, whose output goes to /logs/-.log and is read for progress. No Win32 call -here: the host check is a small C program built with the bundled clang (hostcheck.c). +Extraction runs in this process through tools/extract.py (the package's sibling tools/, which ships with it); fonts +runs the vendored extract_shared_fonts.py on the firmware folder; lift, compile and prepare run the bundled Python on +recomp/gen.py, tools/build_game.py, tools/make_romfs_patches.py and tools/prepare_shaders.py, whose output goes to +/logs/-.log and is read for progress. No Win32 call here: the host check is a small C program built +with the bundled clang (hostcheck.c). -Install folder: exefs/, romfs/ (romfs.bin, index.json), profile/, totk.exe and its DLLs, romfs-index.bin, -romfs-patches/, romfs-patches-xbox/, totk_xlate.exe, the shader state of totk.exe --prepare-shaders, totk.ini, -totk-install.json. +Install folder: exefs/, romfs/ (romfs.bin, index.json), profile/, shared-fonts/ (Chinese and Korean text), totk.exe +and its DLLs, romfs-index.bin, romfs-patches/, romfs-patches-xbox/, totk_xlate.exe, the shader state of +totk.exe --prepare-shaders, totk.ini, totk-install.json. Temp folder (/-): check/ (ExeFS of both inputs, the host check), gen/ (C), build/ (objects), logs/ (kept by the cleanup). """ @@ -217,6 +218,64 @@ def size_of(paths): return sum(p.stat().st_size for p in paths if p.is_file()) +# The Switch system shared fonts (runtime/ov_system.c): TotK falls back to them for Chinese, Korean and Traditional +# Chinese text. tools/vendor/macnativegames/extract_shared_fonts.py writes these names. +SHARED_FONTS = 'shared-fonts' +SHARED_FONT_FILES = ('0-standard.bfttf', '1-chinese-simplified.bfttf', '2-chinese-simplified-extension.bfttf', + '3-chinese-traditional.bfttf', '4-korean.bfttf', '5-nintendo-extension.bfttf') +FONTS_NEED = 32 << 20 # the six BFTTF files and their manifest + + +def requested_lang(ctx): + """The language this run uses: --lang, else the one recorded for the install (Check canonicalizes it).""" + return ini.canonical_language(getattr(ctx.options, 'lang', None) or ctx.state.get('lang')) + + +def firmware_dir(value): + """The firmware NCA folder behind a --firmware path: the folder itself, or its registered/ child (the emulators' + layout, Contents/registered).""" + if not value: + return None + path = Path(value) + return path / 'registered' if (path / 'registered').is_dir() else path + + +def firmware_has_ncas(folder): + """Whether folder holds firmware NCAs: .nca files, or the hashed directories with a 00 file (registered/).""" + try: + entries = list(Path(folder).iterdir()) + except OSError: + return False + return any((e.is_file() and e.suffix.lower() == '.nca') or (e.is_dir() and (e / '00').is_file()) for e in entries) + + +def firmware_stamp(folder): + """A cheap identity of the firmware NCAs: names, sizes and mtimes, not their contents (the folder is ~326 MB).""" + try: + entries = sorted(Path(folder).iterdir()) + except OSError: + return 'missing' + rows = [] + for entry in entries: + if entry.is_dir(): + file = entry / '00' + if not file.is_file(): + continue + elif not entry.is_file() or entry.suffix.lower() != '.nca': + continue + else: + file = entry + st = file.stat() + rows.append((entry.name, st.st_size, st.st_mtime_ns)) + return rows + + +def shared_fonts_ready(dest): + """Whether dest/shared-fonts holds a complete set that the game can load.""" + folder = Path(dest) / SHARED_FONTS + return (folder / 'manifest.json').is_file() and all((folder / name).is_file() for name in SHARED_FONT_FILES) + + # ---- 1. check class Check(Step): @@ -233,13 +292,30 @@ class Check(Step): own = (ctx.temp, *ctx.temp.parents) # a temp folder inside the install folder (installer/cli.py) is ours if ctx.mode == 'install' and dest.is_dir() and any(p not in own for p in dest.iterdir()): raise InstallError('check.dest_not_empty', path=str(dest)) - lang = ini.canonical_language(ctx.options.lang or ctx.state.get('lang')) + lang = requested_lang(ctx) if not lang: raise InstallError('check.lang_missing') if lang not in ini.LANGUAGES: raise InstallError('check.lang_unknown', lang=lang, known=', '.join(ini.LANGUAGES)) + firmware = firmware_dir(getattr(ctx.options, 'firmware', None)) + if firmware is not None: + if not firmware.is_dir(): + raise InstallError('check.firmware_missing', path=str(firmware)) + if not firmware_has_ncas(firmware): + raise InstallError('check.firmware_empty', path=str(firmware)) if lang in ini.NEED_SHARED_FONTS: - rep.warning('warn.lang_needs_fonts', lang=lang) + if firmware is None: + rep.warning('warn.lang_needs_fonts', lang=lang) + elif not shared_fonts_ready(dest): + keys = getattr(ctx.options, 'keys', None) + if not keys or not Path(keys).is_file(): + raise InstallError('check.fonts_keys_needed') + try: + key_values = load_keys(Path(keys)) + except (ValueError, OSError, UnicodeDecodeError): + raise InstallError('check.keys_unreadable') from None + if 'header_key' not in key_values: + raise InstallError('check.keys_no_header_key') ctx.lang = lang ctx.temp.mkdir(parents=True, exist_ok=True) @@ -368,6 +444,8 @@ class Check(Step): romfs = ctx.dest / 'romfs' / 'romfs.bin' if romfs.is_file(): # a resumed extraction replaces it dest_need -= romfs.stat().st_size + if ctx.will_run('fonts'): + dest_need += FONTS_NEED if ctx.will_run('prepare') or ctx.will_run('extract'): dest_need += DEST_EXTRA if ctx.will_run('lift') or ctx.will_run('compile'): @@ -419,7 +497,72 @@ class Check(Step): ctx.state['version'] = found -# ---- 2. extract +# ---- 2. fonts + +class Fonts(Step): + """The Switch system fonts for Chinese, Korean and Traditional Chinese text (tools/extract_fonts.py, which runs the + vendored extract_shared_fonts.py). The firmware folder is optional: without it the setup warns and the game runs + with those glyphs missing. The extraction verifies the firmware NCAs and writes the six BFTTF files the console + puts in shared memory.""" + + id = 'fonts' + + def key(self, ctx): + lang = requested_lang(ctx) + if lang not in ini.NEED_SHARED_FONTS: + return fingerprint('fonts-1', 'not-needed') + firmware = firmware_dir(getattr(ctx.options, 'firmware', None)) + if firmware is None: + return fingerprint('fonts-1', 'no-firmware') + return fingerprint('fonts-1', lang, str(firmware), firmware_stamp(firmware)) + + def current(self, ctx, record): + if requested_lang(ctx) not in ini.NEED_SHARED_FONTS or not getattr(ctx.options, 'firmware', None): + return True + return shared_fonts_ready(ctx.dest) + + def verify(self, ctx, record): + # An update run may omit --firmware; the installed set still has to be there for these languages. + if requested_lang(ctx) in ini.NEED_SHARED_FONTS: + return shared_fonts_ready(ctx.dest) + return True + + def run(self, ctx): + lang = getattr(ctx, 'lang', None) or requested_lang(ctx) + if lang not in ini.NEED_SHARED_FONTS: + return {} + firmware = firmware_dir(getattr(ctx.options, 'firmware', None)) + if firmware is None: + return {} # Check warned: the game runs, some text may not show + keys = getattr(ctx.options, 'keys', None) + if not keys: + raise InstallError('check.fonts_keys_needed') + out = ctx.temp / 'shared-fonts' + remove(out) + ctx.reporter.progress('fonts', 'extract', force=True) + tool = ctx.layout.tools / 'vendor' / 'macnativegames' / 'extract_shared_fonts.py' + code, log = run_tool(ctx, 'fonts', tool, ['--keys', keys, '--firmware', firmware, '--output', out]) + if code: + detail = '' + try: + detail = log.read_text(encoding='utf-8', errors='replace').strip().splitlines()[-1][:300] + except (OSError, IndexError): + pass + raise InstallError('fonts.failed', code=code, detail=detail, log=str(log)) + missing = [name for name in SHARED_FONT_FILES if not (out / name).is_file()] + if missing or not (out / 'manifest.json').is_file(): + raise InstallError('fonts.incomplete', files=', '.join(missing) or 'manifest.json') + ctx.reporter.progress('fonts', 'copy', force=True) + try: + ctx.dest.mkdir(parents=True, exist_ok=True) + remove(ctx.dest / SHARED_FONTS) + shutil.move(str(out), str(ctx.dest / SHARED_FONTS)) + except OSError as e: + raise InstallError('fonts.copy_failed', path=str(ctx.dest / SHARED_FONTS), detail=str(e)) from None + return {'files': len(SHARED_FONT_FILES)} + + +# ---- 3. extract class Extract(Step): id = 'extract' @@ -527,7 +670,7 @@ class Extract(Step): watch.stop() -# ---- 3. lift +# ---- 4. lift EMITTED = re.compile(r'^emitted (\d+)/(\d+) chunks') DISCOVERED = re.compile(r'^(\w+): \d+ functions \(.*discovery') @@ -563,7 +706,7 @@ class Lift(Step): return {} -# ---- 4. compile +# ---- 5. compile NINJA_LINE = re.compile(r'^\[(\d+)/(\d+)\] ') @@ -620,7 +763,7 @@ class Compile(Step): return {'exe_sha256': file_sha256(dest / exe.name)} -# ---- 5. prepare +# ---- 6. prepare TRANSLATED = re.compile(r'gfx_shader: prepare: (\d+) of (\d+) pairs') @@ -718,7 +861,7 @@ class Prepare(Step): return {'xlate_sha256': file_sha256(xlate)} -# ---- 6. clean up +# ---- 7. clean up class Cleanup(Step): id = 'cleanup' @@ -736,7 +879,7 @@ class Cleanup(Step): rep.progress('cleanup', 'temp', force=True) for stale in dest.glob('update-merge-*'): remove(stale) - for name in ('gen', 'build', 'check', 'shader_list.tsv'): + for name in ('gen', 'build', 'check', 'shader_list.tsv', 'shared-fonts'): remove(ctx.temp / name) romfs_problem(dest / 'romfs', expected_update(ctx)) return {} @@ -758,4 +901,4 @@ def romfs_problem(romfs, expected): def all_steps(): - return [Check(), Extract(), Lift(), Compile(), Prepare(), Cleanup()] + return [Check(), Fonts(), Extract(), Lift(), Compile(), Prepare(), Cleanup()] diff --git a/installer/text/en.json b/installer/text/en.json index c792391e..10997e4a 100644 --- a/installer/text/en.json +++ b/installer/text/en.json @@ -1,5 +1,6 @@ { "step.check": "Checking the computer and the game files", + "step.fonts": "Extracting the system fonts", "step.extract": "Extracting the game", "step.lift": "Translating the game code to C", "step.compile": "Compiling {game_exe}", @@ -11,6 +12,8 @@ "phase.check.verify_romfs": "Checking the installed game data", "phase.check.inputs": "Opening the game files with the keys", "phase.check.versions": "Checking the game versions", + "phase.fonts.extract": "Extracting the shared fonts from the firmware", + "phase.fonts.copy": "Copying the shared fonts into the install folder", "phase.extract.hash_base": "Computing the checksum of the base game", "phase.extract.hash_update": "Computing the checksum of the update", "phase.extract.exefs": "Extracting the game code", @@ -67,10 +70,16 @@ "check.base_wrong_version": "The base game is not version 1.0.0 ({modules} differ).", "check.update_wrong_version": "The update is version {found}; this release takes {wanted}.", "check.disk_space": "Not enough free space for {path}: {need_gb} GB needed, {free_gb} GB free.", + "check.firmware_missing": "The firmware folder {path} does not exist. Choose the firmware's Contents/registered folder.", + "check.firmware_empty": "The firmware folder {path} holds no NCA files. Choose the firmware's Contents/registered folder.", + "check.fonts_keys_needed": "The shared fonts for this language are not installed yet; choose prod.keys so the setup can extract them from the firmware.", "extract.exefs_mismatch": "The extracted game code does not match version {version} ({modules}).", "extract.romfs_merge_failed": "Applying the update to the game data failed: {detail}. Details: {log}", "extract.romfs_mismatch": "The extracted game data does not match version {version} (size {size}, checksum {sha256}...).", + "fonts.failed": "Extracting the shared fonts failed (exit code {code}): {detail}. Details: {log}", + "fonts.incomplete": "The shared font extraction did not write every file ({files}).", + "fonts.copy_failed": "The shared fonts cannot be copied into {path}: {detail}", "lift.failed": "Translating the game code failed (exit code {code}). Details: {log}", "compile.failed": "Compiling {game_exe} failed (exit code {code}). Details: {log}", "compile.exe_in_use": "{path} is in use. Close the game and run the installer again.", @@ -81,7 +90,7 @@ "prepare.pack_failed": "Packing the shader cache failed (exit code {code}). Details: {log}", "cleanup.ini_failed": "totk.ini cannot be written: {detail}", - "warn.lang_needs_fonts": "Text in {lang} needs the Switch system fonts, which the installer does not extract; some text may not show.", + "warn.lang_needs_fonts": "Text in {lang} needs the Switch system fonts. Choose the firmware folder (Contents/registered) to install them; without it some text may not show.", "warn.npdm_differs": "main.npdm differs from the reference {version} dump; only the code modules are used.", "warn.xbox_prompts_failed": "The Xbox button prompts could not be prepared (exit code {code}); the game keeps the Switch prompts. Details: {log}", "warn.kernel_old": "Linux {version} lacks the write tracking the game relies on (6.7 or later); the game may run very slowly. Updating the system fixes this.", @@ -94,13 +103,16 @@ "ui.base": "Base game (NSP or XCI)", "ui.update": "Update 1.1.1 or 1.2.1 or 1.4.3 (NSP)", "ui.keys": "prod.keys", + "ui.firmware": "Firmware folder (optional)", "ui.game_language": "Game language", "ui.dest": "Install folder", "ui.browse": "Browse...", "ui.pick_game": "Choose the base game file", "ui.pick_update": "Choose the 1.1.1 or 1.2.1 or 1.4.3 update file", "ui.pick_keys": "Choose prod.keys", + "ui.pick_firmware": "Choose the Switch firmware folder (Contents/registered)", "ui.pick_dest": "Choose the install folder", + "ui.firmware_help": "For Chinese, Korean and Traditional Chinese text: the firmware's Contents/registered folder, for example from Ryujinx or Yuzu. The setup copies the Switch system fonts from it into the install folder.", "ui.type_game": "Game files (NSP, XCI)", "ui.type_nsp": "NSP files", "ui.type_keys": "Key files (*.keys)", @@ -122,6 +134,7 @@ "ui.missing_base": "The base game file does not exist.", "ui.missing_update": "The update file does not exist.", "ui.missing_keys": "The keys file does not exist.", + "ui.missing_firmware": "The firmware folder does not exist.", "ui.running_install": "Installing", "ui.running_resume": "Continuing the earlier setup", "ui.running_update": "Updating the installation", diff --git a/installer/text/ru.json b/installer/text/ru.json index e3a668ba..af4074eb 100644 --- a/installer/text/ru.json +++ b/installer/text/ru.json @@ -1,5 +1,6 @@ { "step.check": "Проверка компьютера и игровых файлов", + "step.fonts": "Извлечение системных шрифтов", "step.extract": "Распаковка игры", "step.lift": "Перевод кода игры в C", "step.compile": "Сборка {game_exe}", @@ -11,6 +12,8 @@ "phase.check.verify_romfs": "Проверка установленных данных игры", "phase.check.inputs": "Открытие игровых файлов с помощью ключей", "phase.check.versions": "Проверка версий игры", + "phase.fonts.extract": "Извлечение общих шрифтов из прошивки", + "phase.fonts.copy": "Копирование общих шрифтов в папку установки", "phase.extract.hash_base": "Подсчёт контрольной суммы базовой игры", "phase.extract.hash_update": "Подсчёт контрольной суммы обновления", "phase.extract.exefs": "Распаковка кода игры", @@ -67,10 +70,16 @@ "check.base_wrong_version": "Базовая игра не версии 1.0.0 (отличаются модули: {modules}).", "check.update_wrong_version": "Версия обновления {found}; этот выпуск принимает {wanted}.", "check.disk_space": "Не хватает свободного места для {path}: нужно {need_gb} ГБ, свободно {free_gb} ГБ.", + "check.firmware_missing": "Папки прошивки {path} нет. Выберите папку Contents/registered прошивки.", + "check.firmware_empty": "В папке прошивки {path} нет файлов NCA. Выберите папку Contents/registered прошивки.", + "check.fonts_keys_needed": "Общие шрифты для этого языка ещё не установлены; выберите prod.keys, чтобы установка извлекла их из прошивки.", "extract.exefs_mismatch": "Распакованный код игры не соответствует версии {version} ({modules}).", "extract.romfs_merge_failed": "Не удалось применить обновление к данным игры: {detail}. Подробности: {log}", "extract.romfs_mismatch": "Распакованные данные игры не соответствуют версии {version} (размер {size}, контрольная сумма {sha256}...).", + "fonts.failed": "Не удалось извлечь общие шрифты (код выхода {code}): {detail}. Подробности: {log}", + "fonts.incomplete": "При извлечении общих шрифтов записаны не все файлы ({files}).", + "fonts.copy_failed": "Не удаётся скопировать общие шрифты в {path}: {detail}", "lift.failed": "Не удалось перевести код игры (код выхода {code}). Подробности: {log}", "compile.failed": "Не удалось собрать {game_exe} (код выхода {code}). Подробности: {log}", "compile.exe_in_use": "Файл {path} занят. Закройте игру и запустите установку снова.", @@ -81,7 +90,7 @@ "prepare.pack_failed": "Не удалось упаковать кэш шейдеров (код выхода {code}). Подробности: {log}", "cleanup.ini_failed": "Не удаётся записать totk.ini: {detail}", - "warn.lang_needs_fonts": "Для языка «{lang}» нужны системные шрифты Switch, которые установка не извлекает; часть текста может не отображаться.", + "warn.lang_needs_fonts": "Для языка «{lang}» нужны системные шрифты Switch. Выберите папку прошивки (Contents/registered), чтобы установить их; без неё часть текста может не отображаться.", "warn.npdm_differs": "main.npdm отличается от эталонного дампа {version}; используются только модули с кодом.", "warn.xbox_prompts_failed": "Не удалось подготовить подсказки кнопок Xbox (код выхода {code}); игра оставит подсказки Switch. Подробности: {log}", "warn.kernel_old": "В Linux {version} нет отслеживания записи, на которое опирается игра (нужна версия 6.7 или новее); игра может работать очень медленно. Обновление системы это исправит.", @@ -94,13 +103,16 @@ "ui.base": "Базовая игра (NSP или XCI)", "ui.update": "Обновление 1.1.1 или 1.2.1 или 1.4.3 (NSP)", "ui.keys": "prod.keys", + "ui.firmware": "Папка прошивки (необязательно)", "ui.game_language": "Язык игры", "ui.dest": "Папка установки", "ui.browse": "Обзор...", "ui.pick_game": "Выберите файл базовой игры", "ui.pick_update": "Выберите файл обновления 1.1.1 или 1.2.1 или 1.4.3", "ui.pick_keys": "Выберите prod.keys", + "ui.pick_firmware": "Выберите папку прошивки Switch (Contents/registered)", "ui.pick_dest": "Выберите папку установки", + "ui.firmware_help": "Для текста на китайском и корейском: папка Contents/registered прошивки, например из Ryujinx или Yuzu. Установка скопирует из неё системные шрифты Switch в папку установки.", "ui.type_game": "Файлы игры (NSP, XCI)", "ui.type_nsp": "Файлы NSP", "ui.type_keys": "Файлы ключей (*.keys)", @@ -122,6 +134,7 @@ "ui.missing_base": "Файл базовой игры не найден.", "ui.missing_update": "Файл обновления не найден.", "ui.missing_keys": "Файл ключей не найден.", + "ui.missing_firmware": "Папки прошивки нет.", "ui.running_install": "Установка", "ui.running_resume": "Продолжение прерванной установки", "ui.running_update": "Обновление установки", diff --git a/tests/installer_gui_test.py b/tests/installer_gui_test.py index 4eb9c228..db9916e9 100644 --- a/tests/installer_gui_test.py +++ b/tests/installer_gui_test.py @@ -30,7 +30,7 @@ from installer import layout as layouts # noqa: E402 from installer import state as statefile # noqa: E402 from installer.events import Reporter # noqa: E402 from installer.runner import Context # noqa: E402 -from installer.steps import all_steps # noqa: E402 +from installer.steps import Fonts, all_steps # noqa: E402 from installer.guilogic import (DONE, FAILED, PENDING, RUNNING, SKIPPED, Catalog, Form, Model, Process, # noqa: E402 amount, describe_event, parse_line, windows_ui_language) @@ -38,7 +38,7 @@ INSTALLED = ROOT / 'build' / 'install-test' FIXTURE = ROOT / 'build' / 'inst-gui' / 'e2e-install' COPIED = ('totk.ini', statefile.NAME) # written by a run, so never linked START = {'type': 'start', 'release': 'r1', 'mode': 'install', 'dest': 'D:\\T', 'log': 'D:\\T.log', - 'steps': ['check', 'extract', 'lift', 'compile', 'prepare', 'cleanup']} + 'steps': ['check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'cleanup']} def fold(*events): @@ -66,7 +66,7 @@ class ModelTest(unittest.TestCase): m = fold(START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'lift': 'current', 'compile': 'current'}}, {'type': 'step', 'step': 'check', 'state': 'started'}) self.assertEqual([m.states[s] for s in m.steps], - [RUNNING, PENDING, SKIPPED, SKIPPED, PENDING, PENDING]) + [RUNNING, PENDING, PENDING, SKIPPED, SKIPPED, PENDING, PENDING]) self.assertEqual(m.current, 'check') m.apply({'type': 'step', 'step': 'check', 'state': 'done', 'seconds': 1.0}) self.assertEqual(m.states['check'], DONE) @@ -137,10 +137,12 @@ class ModelTest(unittest.TestCase): class RunStatesTest(unittest.TestCase): - OK = [START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'lift': 'current', 'compile': 'current', - 'prepare': 'current', 'cleanup': 'not_needed'}}, + OK = [START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'fonts': 'current', 'lift': 'current', + 'compile': 'current', 'prepare': 'current', + 'cleanup': 'not_needed'}}, {'type': 'step', 'step': 'check', 'state': 'started'}, {'type': 'step', 'step': 'check', 'state': 'done'}, {'type': 'step', 'step': 'extract', 'state': 'started'}, {'type': 'step', 'step': 'extract', 'state': 'done'}, + {'type': 'step', 'step': 'fonts', 'state': 'skipped'}, {'type': 'step', 'step': 'lift', 'state': 'skipped'}, {'type': 'step', 'step': 'compile', 'state': 'skipped'}, {'type': 'step', 'step': 'prepare', 'state': 'skipped'}, {'type': 'step', 'step': 'cleanup', 'state': 'done'}, {'type': 'end', 'result': 'ok', 'seconds': 1}] @@ -200,11 +202,20 @@ class FormTest(unittest.TestCase): def test_unknown_language(self): self.assertEqual(self.form(lang='xx').problems(), ['ui.need_lang']) + def test_firmware_folder(self): + firmware = self.dir / 'firmware' + firmware.mkdir() + self.assertEqual(self.form(firmware=str(firmware)).problems(), []) + self.assertEqual(self.form(firmware=str(self.dir / 'no-firmware')).problems(), ['ui.missing_firmware']) + def test_command(self): - cmd = self.form().command('py.exe', root=ROOT, temp=self.dir / 't', verify=True) + firmware = self.dir / 'firmware' + firmware.mkdir() + cmd = self.form(firmware=str(firmware)).command('py.exe', root=ROOT, temp=self.dir / 't', verify_hashes=True) self.assertEqual(cmd[:3], ['py.exe', '-m', 'installer']) - for flag in ('--base', '--update', '--keys', '--lang', '--dest', '--root', '--temp', '--verify'): + for flag in ('--base', '--update', '--keys', '--firmware', '--lang', '--dest', '--root', '--temp', '--verify'): self.assertIn(flag, cmd) + self.assertEqual(cmd[cmd.index('--firmware') + 1], str(firmware)) self.assertEqual(cmd[cmd.index('--lang') + 1], 'en-US') def test_installed_folder_needs_only_itself(self): @@ -244,7 +255,7 @@ class CatalogTest(unittest.TestCase): cat = Catalog('en') ids = ['ui.' + n for n in ('title', 'start', 'cancel', 'open_folder', 'notice', 'space_one', 'space_two', 'process_died', 'result_ok', 'result_failed', 'result_cancelled')] - ids += [f'step.{s}' for s in ('check', 'extract', 'lift', 'compile', 'prepare', 'cleanup')] + ids += [f'step.{s}' for s in ('check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'cleanup')] ids += ['ui.state_' + n for n in ('started', 'done', 'skipped', 'pending', 'running', 'failed')] for lang in ('en', 'ru'): cat.set_language(lang) @@ -334,6 +345,10 @@ def make_fixture(): ctx = Context(layout, FIXTURE, ROOT / 'build' / 'inst-gui' / 'e2e-temp', Reporter(out=io.StringIO()), options, state=state) ctx.steps = all_steps() ctx.plan() + # An install made before the fonts step has no record for it: record it as current when its output is (the + # languages that need the fonts have them in shared-fonts/). + if Fonts().current(ctx, {}): + state['steps'].setdefault('fonts', {'release': state.get('release', ''), 'key': ctx.keys['fonts']}) for step_id, record in state['steps'].items(): record['key'] = ctx.keys[step_id] statefile.save(FIXTURE, state) @@ -346,7 +361,7 @@ class EndToEndTest(unittest.TestCase): def run_cli(form, temp, tries=1): """Runs the CLI in --verify mode through Process, up to `tries` times while the RomFS hash reads wrong.""" for attempt in range(tries): - p = Process(form.command(sys.executable, root=ROOT, temp=temp, verify=True), cwd=ROOT) + p = Process(form.command(sys.executable, root=ROOT, temp=temp, verify_hashes=True), cwd=ROOT) model, stray = Model(), [] t0, code = time.monotonic(), None while code is None and time.monotonic() - t0 < 600: @@ -384,7 +399,7 @@ class EndToEndTest(unittest.TestCase): def test_cancel_during_the_check(self): cmd = Form(dest=str(make_fixture())).command(sys.executable, root=ROOT, temp=ROOT / 'build' / 'inst-gui' / 'e2e-temp', - verify=True) + verify_hashes=True) p = Process(cmd, cwd=ROOT) model, t0, code, cancelled = Model(), time.monotonic(), None, False while code is None and time.monotonic() - t0 < 120: diff --git a/tests/installer_test.py b/tests/installer_test.py index 036d897a..bc248f58 100644 --- a/tests/installer_test.py +++ b/tests/installer_test.py @@ -291,6 +291,108 @@ class CleanupTest(unittest.TestCase): self.assertEqual(self.run_cleanup(), 'cleanup.romfs_unreadable') +class FontsTest(unittest.TestCase): + """The fonts step: keyed by language and firmware, a no-op without firmware, writes dest/shared-fonts.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.dest, temp, self.firmware = base / 'dest', base / 'temp', base / 'firmware' + temp.mkdir() + self.firmware.mkdir() + (self.firmware / 'a.nca').write_bytes(b'nca') + self.keys = base / 'prod.keys' + self.keys.write_text('header_key = 00\n') + self.ctx = SimpleNamespace(dest=self.dest, temp=temp, lang='zh-Hans', state={}, + layout=SimpleNamespace(tools=ROOT / 'tools', root=ROOT), + reporter=Reporter(out=io.StringIO()), + options=SimpleNamespace(lang='zh-Hans', firmware=self.firmware, keys=str(self.keys))) + self.saved_run_tool = steps.run_tool + + def tearDown(self): + steps.run_tool = self.saved_run_tool + self.ctx.reporter.close() + self.tmp.cleanup() + + def fake_tool(self, files=None, code=0): + """Stand-in for extract_shared_fonts.py: writes the requested files where --output points.""" + def run(ctx, step, script, args): + out = Path(args[args.index('--output') + 1]) + out.mkdir(parents=True) + for name in steps.SHARED_FONT_FILES if files is None else files: + (out / name).write_bytes(b'bfttf') + (out / 'manifest.json').write_text('{}') + log = ctx.temp / 'fonts.log' + log.write_text('error line\n') + return code, log + steps.run_tool = run + + def test_all_steps_order(self): + self.assertEqual([s.id for s in steps.all_steps()], + ['check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'cleanup']) + + def test_extracts_into_the_install_folder(self): + self.fake_tool() + self.assertEqual(steps.Fonts().run(self.ctx), {'files': 6}) + self.assertTrue(steps.shared_fonts_ready(self.dest)) + self.assertEqual(sorted(p.name for p in (self.dest / 'shared-fonts').iterdir()), + sorted((*steps.SHARED_FONT_FILES, 'manifest.json'))) + + def test_noop_without_firmware(self): + self.ctx.options.firmware = None + self.fake_tool() + self.assertEqual(steps.Fonts().run(self.ctx), {}) + self.assertFalse((self.dest / 'shared-fonts').exists()) + + def test_noop_for_other_languages(self): + self.ctx.lang, self.ctx.options.lang = 'en-US', 'en-US' + self.fake_tool() + self.assertEqual(steps.Fonts().run(self.ctx), {}) + self.assertFalse((self.dest / 'shared-fonts').exists()) + + def test_failed_extraction_keeps_the_old_folder(self): + self.fake_tool() + steps.Fonts().run(self.ctx) + self.fake_tool(code=1) + with self.assertRaises(steps.InstallError) as e: + steps.Fonts().run(self.ctx) + self.assertEqual(e.exception.id, 'fonts.failed') + self.assertTrue(steps.shared_fonts_ready(self.dest)) + + def test_incomplete_output_is_an_error(self): + self.fake_tool(files=steps.SHARED_FONT_FILES[:1]) + with self.assertRaises(steps.InstallError) as e: + steps.Fonts().run(self.ctx) + self.assertEqual(e.exception.id, 'fonts.incomplete') + + def test_current_tracks_language_and_output(self): + fonts = steps.Fonts() + self.assertFalse(fonts.current(self.ctx, {})) + self.fake_tool() + fonts.run(self.ctx) + self.assertTrue(fonts.current(self.ctx, {})) + self.ctx.options.lang = 'en-US' + self.assertTrue(fonts.current(self.ctx, {})) + self.ctx.options.lang = 'zh-Hans' + (self.dest / 'shared-fonts' / '0-standard.bfttf').unlink() + self.assertFalse(fonts.current(self.ctx, {})) + + def test_key_changes_with_language_and_firmware(self): + fonts = steps.Fonts() + zh = fonts.key(self.ctx) + self.ctx.options.lang = 'en-US' + self.assertNotEqual(zh, fonts.key(self.ctx)) + self.ctx.options.lang = 'zh-Hans' + (self.firmware / 'b.nca').write_bytes(b'other') + self.assertNotEqual(zh, fonts.key(self.ctx)) + + def test_registered_child_is_used(self): + nested = self.firmware / 'registered' + nested.mkdir() + (nested / 'c.nca').write_bytes(b'nca') + self.assertEqual(steps.firmware_dir(self.firmware), nested) + + class EventsTest(unittest.TestCase): def test_lines_are_json_and_the_log_is_english(self): with tempfile.TemporaryDirectory() as d: diff --git a/totk.ini.example b/totk.ini.example index 6d3d5f1e..bbd2545c 100644 --- a/totk.ini.example +++ b/totk.ini.example @@ -215,6 +215,9 @@ [files] # Folder with RomFS mod files that replace or add game files. Default: none. # romfs_mods = +# Folder with the Switch shared fonts (BFTTF files, tools/extract_fonts.py output), for Chinese and Korean text. +# Default: shared-fonts next to totk.exe. TOTK_SHARED_FONTS overrides it. +# shared_fonts = [diagnostics] # Extra lines in the log, comma separated: svc, host, ipc, fs, gfx, perf, sched, audio, present or all. The menu's