mirror of
https://github.com/kuraeow/ultratears.git
synced 2026-11-07 10:16:00 -10:00
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:
1 parent
7ad35b8fa9
commit
b56f04f04f
5 files changed
+328
-29
No files matched your search
+16
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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__':
|
||||
|
||||
Reference in new issue
Block a user