setup.py --json and --check-dump for the launcher; hardware check as cases with codes

--json: events among the log lines (plan with each step's share of a build from nothing, step run / done / skip with
seconds, progress of the step running from tools/progress.py bars and ninja's [n/m], hw cases, ask answered on
stdin, error with a code and its arguments, done). --check-dump <folder>: whether it holds a usable dump, found in the
folder itself, the one above or one below (what a player drops on the launcher), with a code when not. Failures carry
codes so that the launcher words them in the player's language.

tools/hw_check.py: what the PC lacks as cases (no Vulkan, Vulkan below 1.3, no shader objects with or without the
Khronos layer, no external host memory, no swapchain, under 4 GB of video memory, under 16 GB of RAM, no AVX2, not
enough disk space, and a temporary note that AMD cards are untested); setup's check step asks only when the game
cannot run and stops only when the disk is too small. The PC does not sleep while setup runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
kuraeowandClaude Opus 5.5 committed 2026-10-31 00:10:45 +03:00
1 parent 7ad35b8fa9
commit b56f04f04f
5 files changed
+328 -29

No files matched your search

+16 -1
View File
@@ -181,7 +181,22 @@ def main():
# The user works on this PC during builds: ninja starts suspended, gets the first --cpus logical CPUs and
# below-normal priority, then runs; every clang it starts inherits both, so the other CPUs stay free.
ninja = [str(CLANG64 / 'ninja.exe'), '-C', str(ROOT), '-f', 'build/build.ninja', f'-j{a.j}', '-k', '0']
sys.exit(run_limited(ninja, env, (1 << a.cpus) - 1))
on_line = None
import progress
if progress.JSON: # setup.py --json: ninja's [n/m] as progress events, its lines on to the log
import re
bar = progress.Bar('compiling', 0, 'files')
status = re.compile(r'^\[(\d+)/(\d+)\] ')
def on_line(line):
m = status.match(line)
if m:
bar.update(int(m.group(1)), int(m.group(2)))
print(line, flush=True)
rc = run_limited(ninja, env, (1 << a.cpus) - 1, on_line)
if on_line:
bar.finish()
sys.exit(rc)
def run_limited(cmd, env, mask, on_line=None):
+12 -2
View File
@@ -37,9 +37,10 @@ class ExtensionProperties(ctypes.Structure):
LAYER = b'VK_LAYER_KHRONOS_shader_object'
def gpus(layer=None):
def gpus(layer=None, details=None):
"""[(name, type, 'major.minor.patch', missing requirements)] of every graphics card the Vulkan loader reports, seen
through layer when given; None when there is no Vulkan at all (or the layer is not installed)."""
through layer when given; None when there is no Vulkan at all (or the layer is not installed). details (a list):
filled with {'vendor': PCI vendor id, 'vram': bytes of the largest device-local heap} per card, in the same order."""
try:
vk = ctypes.WinDLL('vulkan-1.dll')
except OSError:
@@ -70,6 +71,15 @@ def gpus(layer=None):
have = {e.extensionName.decode(errors='replace') for e in exts[:m.value]}
missing = (['Vulkan 1.3'] if api < API_1_3 else []) + [x for x in NEEDED if x not in have]
out.append((name, TYPES.get(kind, 'other'), f'{api >> 22}.{(api >> 12) & 0x3ff}.{api & 0xfff}', missing))
if details is not None:
# VkPhysicalDeviceMemoryProperties: 32 memory types (8 bytes each) after their count, then the heap
# count at 260 and 16 heaps of {VkDeviceSize size, flags} (16 bytes) from 264; DEVICE_LOCAL is flag bit 0
mem = ctypes.create_string_buffer(520)
vk.vkGetPhysicalDeviceMemoryProperties(ctypes.c_void_p(d), mem)
heaps = int.from_bytes(mem.raw[260:264], 'little')
local = [int.from_bytes(mem.raw[264 + 16 * h:272 + 16 * h], 'little') for h in range(min(heaps, 16))
if int.from_bytes(mem.raw[272 + 16 * h:276 + 16 * h], 'little') & 1]
details.append({'vendor': int.from_bytes(props.raw[8:12], 'little'), 'vram': max(local, default=0)})
return out
finally:
vk.vkDestroyInstance(inst, None)
+137
View File
@@ -0,0 +1,137 @@
"""What this PC lacks for the build and the game, as a list of cases for the launcher (and setup.py's console).
Each case: {'code', 'sev', 'args'}. sev 'stop': the game will not run as it is (the player may still build: the launcher
asks "continue anyway"); 'block': the build cannot run (not enough disk space); 'warn': below the recommended, the build
goes on; 'info': a remark. The launcher has the texts for the codes in its languages; args fill them in.
no_vulkan stop no Vulkan driver at all
vulkan_old gpu, version stop the best card's driver offers Vulkan below 1.3
no_shader_object gpu, layer stop no VK_EXT_shader_object (AMD RX 400/500, Vega, Intel HD); layer: the Khronos
shader object layer is installed and the game could run through it
no_ext_mem_host gpu stop no VK_EXT_external_memory_host
no_swapchain gpu stop no VK_KHR_swapchain (a compute-only device)
vram_low gpu, gb warn less than 4 GB of video memory
ram_low gb warn less than 16 GB of RAM
cpu_no_avx2 warn the code is built for SSE4.2 (slower)
disk_space need_gb, free_gb, drive block
amd_untested gpu info [temporary] AMD cards are not tested yet (dev-local/docs/issues.md, "#2, #16,
#23"): remove once players with RDNA report that the game runs
usage: python tools/hw_check.py [folder] (prints the cases as JSON lines; folder: where the build goes, for disk space)
"""
import ctypes
import json
import os
import pathlib
import shutil
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import gpu_check # noqa: E402
INSTALL_GB = 36 # a build from nothing at its peak (data 27 GB, objects 2.7, tools 0.8, the shader export)
VRAM_GB, RAM_GB = 4, 16
AMD = 0x1002
RANK = {'discrete': 0, 'integrated': 1, 'virtual': 2, 'other': 3, 'CPU': 9}
def gpu_cases():
details = []
found = gpu_check.gpus(details=details)
if found is None:
return [{'code': 'no_vulkan', 'sev': 'stop', 'args': {}}]
cards = [(f, d) for f, d in zip(found, details) if f[1] != 'CPU']
if not cards:
return [{'code': 'no_vulkan', 'sev': 'stop', 'args': {}}]
ok = [(f, d) for f, d in cards if not f[3]]
if ok: # the game takes a card that has everything: the discrete one first
(name, kind, api, _), d = min(ok, key=lambda c: RANK.get(c[0][1], 5))
out = []
if d['vram'] and d['vram'] < VRAM_GB * 2**30 * 0.95 and kind == 'discrete':
out.append({'code': 'vram_low', 'sev': 'warn', 'args': {'gpu': name, 'gb': round(d['vram'] / 2**30, 1)}})
if d['vendor'] == AMD:
out.append({'code': 'amd_untested', 'sev': 'info', 'args': {'gpu': name}})
return out
(name, kind, api, missing), d = min(cards, key=lambda c: (len(c[0][3]), RANK.get(c[0][1], 5))) # the nearest
if 'Vulkan 1.3' in missing:
return [{'code': 'vulkan_old', 'sev': 'stop', 'args': {'gpu': name, 'version': api}}]
if 'VK_EXT_shader_object' in missing:
layered = gpu_check.gpus(gpu_check.LAYER) or []
layer = any(not m for _, k, _, m in layered if k != 'CPU')
return [{'code': 'no_shader_object', 'sev': 'stop', 'args': {'gpu': name, 'layer': layer}}]
if 'VK_EXT_external_memory_host' in missing:
return [{'code': 'no_ext_mem_host', 'sev': 'stop', 'args': {'gpu': name}}]
return [{'code': 'no_swapchain', 'sev': 'stop', 'args': {'gpu': name}}]
class MemoryStatus(ctypes.Structure):
_fields_ = [('dwLength', ctypes.c_uint32), ('dwMemoryLoad', ctypes.c_uint32), ('ullTotalPhys', ctypes.c_uint64),
('ullAvailPhys', ctypes.c_uint64), ('ullTotalPageFile', ctypes.c_uint64), ('ullAvailPageFile', ctypes.c_uint64),
('ullTotalVirtual', ctypes.c_uint64), ('ullAvailVirtual', ctypes.c_uint64), ('ullAvailExtendedVirtual', ctypes.c_uint64)]
def ram_bytes():
m = MemoryStatus(ctypes.sizeof(MemoryStatus))
return m.ullTotalPhys if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)) else 0
def avx2():
return bool(ctypes.windll.kernel32.IsProcessorFeaturePresent(40)) # PF_AVX2_INSTRUCTIONS_AVAILABLE
def folder_size(p):
total = 0
for root, _, files in os.walk(p):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
def disk_case(folder):
"""Enough free space where the build goes? What is already built there (an update) counts as taken."""
folder = pathlib.Path(folder)
have = sum(folder_size(folder / d) for d in ('data', 'build', 'deps') if (folder / d).is_dir())
need = max(0, INSTALL_GB * 2**30 - have)
probe = folder
while not probe.exists() and probe.parent != probe:
probe = probe.parent
free = shutil.disk_usage(probe).free
if free >= need:
return []
return [{'code': 'disk_space', 'sev': 'block',
'args': {'need_gb': round(need / 2**30), 'free_gb': round(free / 2**30), 'drive': str(probe.resolve().drive or probe)}}]
def cases(folder):
out = gpu_cases()
ram = ram_bytes()
if ram and ram < RAM_GB * 2**30 * 0.9: # a 16 GB PC reports a little less (memory the firmware keeps)
out.append({'code': 'ram_low', 'sev': 'warn', 'args': {'gb': round(ram / 2**30)}})
if not avx2():
out.append({'code': 'cpu_no_avx2', 'sev': 'warn', 'args': {}})
return out + disk_case(folder)
def text(case):
"""An English line for the console (the launcher has its own texts)."""
a = case['args']
return {
'no_vulkan': 'no Vulkan graphics driver was found: install the current driver for your NVIDIA, AMD or Intel card',
'vulkan_old': f'the {a.get("gpu")} driver offers Vulkan {a.get("version")}; 1.3 is needed: updating the driver usually helps',
'no_shader_object': f'{a.get("gpu")} lacks VK_EXT_shader_object (usual for AMD RX 400/500, Vega and Intel HD)' +
(': it can run through the installed Khronos shader object layer, slower and with glitches' if a.get('layer') else ''),
'no_ext_mem_host': f'the {a.get("gpu")} driver lacks VK_EXT_external_memory_host: update the driver',
'no_swapchain': f'{a.get("gpu")} cannot show pictures (no VK_KHR_swapchain)',
'vram_low': f'{a.get("gpu")} has {a.get("gb")} GB of video memory; 4 GB or more is recommended',
'ram_low': f'this PC has {a.get("gb")} GB of RAM; 16 GB or more is recommended',
'cpu_no_avx2': 'the CPU lacks AVX2: the game is built for SSE4.2 and runs a little slower',
'disk_space': f'the build needs {a.get("need_gb")} GB on {a.get("drive")}, {a.get("free_gb")} GB is free',
'amd_untested': f'{a.get("gpu")}: AMD cards are not tested yet; please report how the game runs',
}.get(case['code'], case['code'])
if __name__ == '__main__':
for c in cases(sys.argv[1] if len(sys.argv) > 1 else '.'):
print(json.dumps(c, ensure_ascii=False))
+29 -1
View File
@@ -2,11 +2,22 @@
Bar: work of a known size (bytes, files, shaders). Ticker: work of unknown length, its time so far once a second.
When the output is not a console (a log file, a pipe) a Bar prints a line every 10 % and a Ticker prints nothing.
With ULTRS_JSON=1 (setup.py --json, for the launcher) a Bar prints events instead: {"ev": "progress", "label", "done",
"total", "eta"} at most twice a second, one line each; setup.py says which step they belong to.
"""
import json
import os
import sys
import threading
import time
JSON = os.environ.get('ULTRS_JSON') == '1'
def emit(ev, **fields):
"""One event line for the launcher (setup.py --json)."""
print(json.dumps({'ev': ev, **fields}, ensure_ascii=False), flush=True)
def clock(seconds):
s = max(0, int(seconds))
@@ -62,6 +73,9 @@ class Bar:
def note(self, text):
"""A line of its own (a source that does not answer...), the progress line drawn again below it."""
if JSON:
print(f' {text}', flush=True)
return
with self.lock:
if self.tty:
sys.stdout.write('\r' + ' ' * self.width + '\r')
@@ -69,6 +83,14 @@ class Bar:
print(f' {text}', flush=True)
self.last = 0
def eta(self):
"""Seconds left from the recent speed, or None."""
now = time.monotonic()
t1, d1 = self.samples[0]
if not self.total or now - t1 < 1 or self.done <= d1 or now - self.t0 < 3:
return None
return (self.total - self.done) / ((self.done - d1) / (now - t1))
def update(self, done, total=None, force=False):
with self.lock:
if total is not None:
@@ -77,6 +99,12 @@ class Bar:
now = time.monotonic()
if now - self.samples[-1][0] >= 0.5:
self.samples.append((now, done))
if JSON:
if force or now - self.last >= 0.5:
self.last = now
eta = self.eta()
emit('progress', label=self.label, done=self.done, total=self.total, eta=round(eta) if eta else None)
return
if not force and now - self.last < 0.3:
return
self.last = now
@@ -100,7 +128,7 @@ class Bar:
self.stop.set()
with self.lock:
self.update(self.done, force=True)
if self.tty:
if self.tty and not JSON:
sys.stdout.write('\n')
sys.stdout.flush()
+134 -25
View File
@@ -4,6 +4,12 @@ From a Git checkout the build goes to dist\\, or to --output <folder>: the track
builder package or a source folder without Git setup runs in place. Build tools this PC lacks are downloaded into
deps\\ from where they are published (tools/deps_install.py, pinned in tools/deps.json); a builder package has them.
usage: python tools/setup.py [--output <build folder>] [--dump <folder with exefs\\ and romfs\\>] [--cpus N]
python tools/setup.py --check-dump <folder> [--json] (is it a usable dump? for the launcher's folder screens)
--json (the launcher): events on stdout, one JSON object a line, among the plain log lines: {"ev": "plan", "steps":
[{"id", "weight"}]} (the share of a build from nothing, in %), {"ev": "step", "id", "state": "run" / "done" / "skip",
"secs", "note"}, {"ev": "progress", "label", "done", "total", "eta"} (of the step running), {"ev": "hw", "cases"}
(tools/hw_check.py), {"ev": "ask", "id": "hw"} (answered by a line "yes" / "no" on stdin), {"ev": "error", "id",
"code", "args", "message"}, {"ev": "done", "secs"}; --check-dump: {"ev": "dump", "ok", "code", "args", "path"}.
The dump is game\\ in the checkout/build folder unless --dump names another (remembered for later runs); run it again
to resume, or to rebuild what changed after an update of the port.
The dump: the ExeFS and RomFS of The Legend of Zelda: Tears of the Kingdom with update 1.4.3 applied, as plain files,
@@ -65,16 +71,26 @@ OUTPUT_OWN = {'setup.bat', 'VERSION.txt', 'THIRD-PARTY-NOTICES.md', 'licenses',
class Fail(Exception):
pass
def __init__(self, message, code='failed', **args):
"""code (and args): for the launcher, which words the failure in the player's language (setup.py --json)."""
super().__init__(message)
self.code, self.fields = code, args
ASKED = 0.0 # seconds spent waiting for the player's answers: not build time (a build "took 46 min" with 28 of them
# at the question whether to delete the dump, 2026-10-29)
def ask(question):
"""The player's answer on a console ('' without one, or on Ctrl+C), lower case."""
def ask(question, ask_id=None):
"""The player's answer on a console ('' without one, or on Ctrl+C), lower case. --json: an "ask" event, the answer
a line on stdin from the launcher."""
global ASKED
if progress.JSON and ask_id:
t0 = time.time()
progress.emit('ask', id=ask_id)
line = sys.stdin.readline() if sys.stdin else ''
ASKED += time.time() - t0
return line.strip().lower()
if not (sys.stdin and sys.stdin.isatty()):
return ''
t0 = time.time()
@@ -179,6 +195,8 @@ def build_from_checkout(a):
cmd += ['--only', a.only]
if a.keep_shader_work:
cmd += ['--keep-shader-work']
if a.json:
cmd += ['--json']
return subprocess.run(cmd, cwd=home, env=dict(os.environ, PYTHONNOUSERSITE='1')).returncode
@@ -229,39 +247,69 @@ def step_check(dump, state):
raise Fail(f'the Python package {mod} is missing from {sys.executable}: delete the deps folder and run '
'setup again to reinstall the build tools')
toolchain.env()
if not os.environ.get('ULTRS_SKIP_GPU_CHECK'): # before an hour of building for a card the game cannot use
import gpu_check
verdict, text = gpu_check.check()
if verdict == 'ok':
say(f' graphics: {text}')
else: # the player decides: an unsupported card may still be worth a try (ULTRS_SKIP_GPU_CHECK=1: no question)
say(f' graphics: {text}')
if ask(' Build anyway? [y/N] ') not in ('y', 'yes', 'д', 'да'):
raise Fail('stopped at the graphics card check (ULTRS_SKIP_GPU_CHECK=1 builds without asking)')
hardware()
if toolchain.XLATE is None:
raise Fail('the shader translator deps\\shaders\\ultrs-shaders.exe is missing: run setup again to build it')
if packed(state) and all(ok_module(DATA / 'exefs' / n, h) for n, h in EXEFS.items()):
return 'Tears of the Kingdom 1.4.3, already taken in (data\\exefs, data\\romfs): the dump is no longer needed'
check_dump(dump)
return 'Tears of the Kingdom 1.4.3 (the code modules match)'
def hardware():
"""What this PC lacks (tools/hw_check.py), before an hour of building: not enough disk space stops the build; a card
the game cannot run on asks whether to build anyway (ULTRS_SKIP_GPU_CHECK=1: no graphics question)."""
import hw_check
cases = hw_check.cases(ROOT)
if os.environ.get('ULTRS_SKIP_GPU_CHECK'):
cases = [c for c in cases if c['sev'] != 'stop']
if progress.JSON:
progress.emit('hw', cases=cases)
for c in cases:
say(f' {c["sev"]}: {hw_check.text(c)}')
block = [c for c in cases if c['sev'] == 'block']
if block:
raise Fail(hw_check.text(block[0]), block[0]['code'], **block[0]['args'])
if any(c['sev'] == 'stop' for c in cases) and ask(' Build anyway? [y/N] ', 'hw') not in ('y', 'yes', 'д', 'да'):
raise Fail('stopped at the hardware check (ULTRS_SKIP_GPU_CHECK=1 builds without asking)', 'hw_declined')
if not cases:
say(' hardware: everything the game needs is here')
def find_dump(path):
"""The folder with exefs\\ and romfs\\ for what the player gave: the folder itself, one of its subfolders (the
folder above the dump was dropped) or the folder above it (exefs or romfs itself was dropped). None if none."""
path = pathlib.Path(path)
if not path.is_dir():
return None
for c in [path, path.parent] + sorted(p for p in path.iterdir() if p.is_dir()):
if (c / 'exefs').is_dir() and (c / 'romfs').is_dir():
return c
return None
def check_dump(dump):
"""Is dump the extracted ExeFS and RomFS of TOTK 1.4.3? Fail with a code for the launcher when not."""
if not dump.is_dir():
raise Fail(f'{dump} does not exist: put your extracted game there (game\\exefs and game\\romfs), '
'or give its folder with --dump')
'or give its folder with --dump', 'dump_missing', path=str(dump))
for p in list(dump.iterdir()) + list((dump / 'exefs').glob('*') if (dump / 'exefs').is_dir() else []):
if p.suffix.lower() in ENCRYPTED:
raise Fail(f'{p.name}: encrypted packages are not accepted. Give the extracted exefs and romfs folders '
'(this project decrypts nothing and reads no keys)')
'(this project decrypts nothing and reads no keys)', 'dump_encrypted', file=p.name)
exefs, romfs = dump / 'exefs', dump / 'romfs'
if not exefs.is_dir() or not romfs.is_dir():
raise Fail(f'{dump} must hold the folders exefs and romfs (extracted, as plain files)')
raise Fail(f'{dump} must hold the folders exefs and romfs (extracted, as plain files)', 'dump_no_folders')
for name, want in EXEFS.items():
f = exefs / name
if not f.is_file():
raise Fail(f'exefs\\{name} is missing')
raise Fail(f'exefs\\{name} is missing', 'dump_incomplete', file=f'exefs/{name}')
if sha256(f) != want:
raise Fail(f'exefs\\{name} is not the one of Tears of the Kingdom 1.4.3. Extract the ExeFS again with '
'update 1.4.3 applied (the base game 1.0.0 and other updates are not supported)')
'update 1.4.3 applied (the base game 1.0.0 and other updates are not supported)', 'dump_version')
if not (romfs / 'Pack/ZsDic.pack.zs').is_file():
raise Fail(f'{romfs} does not look like the RomFS of Tears of the Kingdom (Pack\\ZsDic.pack.zs is missing)')
return 'Tears of the Kingdom 1.4.3 (the code modules match)'
raise Fail(f'{romfs} does not look like the RomFS of Tears of the Kingdom (Pack\\ZsDic.pack.zs is missing)',
'dump_not_totk')
def packed(state):
@@ -281,7 +329,8 @@ def step_romfs(dump, state, cpus):
n, size, content = pack(dump / 'romfs', out, threads=16, progress=say)
if n != ROMFS_FILES or size < ROMFS_BYTES or content != ROMFS_CONTENT:
raise Fail(f'the RomFS is not the one of Tears of the Kingdom 1.4.3 ({n} files, expected {ROMFS_FILES}; '
'content differs). Extract the RomFS again with update 1.4.3 applied; mods do not go here')
'content differs). Extract the RomFS again with update 1.4.3 applied; mods do not go here',
'romfs_version')
state['romfs'] = content
return f'{n} files, {size / 2**30:.1f} GiB, content verified'
@@ -465,9 +514,18 @@ def main():
ap.add_argument('--only', choices=[s for s, _ in STEPS], help='run one step only')
ap.add_argument('--keep-shader-work', action='store_true', help='keep data/shaders/work after packing (development)')
ap.add_argument('--in-place', action='store_true', help=argparse.SUPPRESS)
ap.add_argument('--json', action='store_true', help='events for the launcher on stdout (see the top of this file)')
ap.add_argument('--check-dump', type=pathlib.Path, metavar='FOLDER', help='only say whether FOLDER is a usable dump')
a = ap.parse_args()
global KEEP_SHADER_WORK
KEEP_SHADER_WORK = a.keep_shader_work
if a.json: # the tools setup starts read it too (tools/progress.py); UTF-8 whatever the console's code page
os.environ['ULTRS_JSON'] = '1'
os.environ['PYTHONIOENCODING'] = 'utf-8'
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
progress.JSON = True
if a.check_dump:
sys.exit(check_dump_only(a.check_dump))
no_quick_edit()
if is_checkout() and not a.in_place:
sys.exit(build_from_checkout(a))
@@ -476,19 +534,24 @@ def main():
# The build runs on a Python 3.14 with the build's packages (deps_install.python_for), whichever Python started
# setup: a source folder without Git (GitHub's Download ZIP) went on with the player's own, which may lack them or
# be a version they do not install on. Then the rest of the tools, installed when this PC lacks them.
if progress.JSON and not os.environ.get('ULTRATEARS_SETUP_OWN_PYTHON'):
progress.emit('plan', steps=[{'id': s, 'weight': w} for s, w in STEP_WEIGHTS.items()])
progress.emit('step', id='tools', state='run')
t_tools = time.time()
try:
py = own_python(ROOT)
except deps_install.DepsError as e:
say(f'\nERROR preparing the build tools: {e}')
sys.exit(1)
fail_tools(e)
if pathlib.Path(sys.executable).resolve() != py.resolve() and not os.environ.get('ULTRATEARS_SETUP_OWN_PYTHON'):
env = dict(os.environ, PYTHONNOUSERSITE='1', ULTRATEARS_SETUP_OWN_PYTHON='1')
sys.exit(subprocess.run([str(py), str(ROOT / 'tools/setup.py')] + sys.argv[1:], cwd=ROOT, env=env).returncode)
try:
deps_install.ensure_tools(ROOT)
except deps_install.DepsError as e:
say(f'\nERROR preparing the build tools: {e}')
sys.exit(1)
fail_tools(e)
if progress.JSON:
progress.emit('step', id='tools', state='done', secs=round(time.time() - t_tools))
awake()
importlib.reload(toolchain)
state = load_state()
if a.dump:
@@ -505,16 +568,62 @@ def main():
if a.only and name != a.only and name != 'check':
continue
say(f'[{i}/{len(STEPS)}] {name}' + (f' ({progress.clock(time.time() - t_all - ASKED)} since the start)' if i > 1 else ''))
if progress.JSON:
progress.emit('step', id=name, state='run')
t0, asked0 = time.time(), ASKED
try:
result = fn(dump, state) if name == 'check' else fn(dump, state, a.cpus)
except Fail as e:
say(f'\nERROR in step "{name}": {e}')
if progress.JSON:
progress.emit('error', id=name, code=e.code, args=e.fields, message=str(e))
save_state(state)
sys.exit(1)
save_state(state)
say(f' {result or "ok"} ({progress.clock(time.time() - t0 - (ASKED - asked0))})')
secs = time.time() - t0 - (ASKED - asked0)
say(f' {result or "ok"} ({progress.clock(secs)})')
if progress.JSON:
skipped = result in ('up to date', 'already packed') # the step had nothing to do (an update, a resume)
progress.emit('step', id=name, state='skip' if skipped else 'done', secs=round(secs), note=result or '')
say(f'\nDone in {progress.clock(time.time() - t_all - ASKED)}. Start the game: UltraTears.exe in {ROOT}')
if progress.JSON:
progress.emit('done', secs=round(time.time() - t_all - ASKED))
# The share of each step in a build from nothing, in % (a clean 0.2.0 package on 8 CPUs, 2026-10-30: romfs 2:19, lift
# 1:19, compile 15:39, shaders 2:54, textures 10:08; tools: a source build's first download). The launcher's overall bar
# weighs each step's own progress by these; steps already done count in full.
STEP_WEIGHTS = {'tools': 2, 'check': 1, 'romfs': 7, 'lift': 4, 'compile': 47, 'shaders': 9, 'textures': 30, 'finish': 0}
def fail_tools(e):
say(f'\nERROR preparing the build tools: {e}')
if progress.JSON:
progress.emit('error', id='tools', code='tools', args={}, message=str(e))
sys.exit(1)
def awake():
"""The PC does not go to sleep while setup runs (an hour of building on a laptop); the display may still turn off."""
if os.name == 'nt':
import ctypes
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED
def check_dump_only(path):
"""--check-dump: whether path (or the folder above or below it) holds a usable dump, without building."""
path = pathlib.Path(path).resolve()
found = find_dump(path)
try:
check_dump(found or pathlib.Path(path))
ok, code, fields, message = True, 'ok', {}, 'Tears of the Kingdom 1.4.3'
except Fail as e:
ok, code, fields, message = False, e.code, e.fields, str(e)
if progress.JSON:
progress.emit('dump', ok=ok, code=code, args=fields, path=str(found or path), message=message)
else:
say(f'{found or path}: {message}')
return 0 if ok else 1
if __name__ == '__main__':