diff --git a/builder/HookshotBuilder/MainWindow.xaml b/builder/HookshotBuilder/MainWindow.xaml index e358b23d..9abde96b 100644 --- a/builder/HookshotBuilder/MainWindow.xaml +++ b/builder/HookshotBuilder/MainWindow.xaml @@ -170,6 +170,9 @@ + + diff --git a/builder/HookshotBuilder/MainWindow.xaml.cs b/builder/HookshotBuilder/MainWindow.xaml.cs index d68b5141..9e77d777 100644 --- a/builder/HookshotBuilder/MainWindow.xaml.cs +++ b/builder/HookshotBuilder/MainWindow.xaml.cs @@ -255,6 +255,7 @@ sealed class BuilderSettings public string? Nup { get; set; } public string? Romfs { get; set; } public string? Exefs { get; set; } + public bool PrepareLater { get; set; } // ("Prepare shaders now" unticked) } static string SettingsPath => System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "HookshotBuilder", "builder-settings.json"); @@ -264,6 +265,7 @@ void ApplySettings() BuilderSettings? s = null; try { s = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)); } catch (Exception) { } if (s == null) return; + PrepareTick.IsChecked = !s.PrepareLater; if (!string.IsNullOrEmpty(s.Out)) OutPath.Text = s.Out; if (s.Romfs != null && s.Exefs != null && Directory.Exists(s.Romfs) && File.Exists(System.IO.Path.Combine(s.Exefs, "main"))) { @@ -287,7 +289,8 @@ void SaveSettings() { try { - var s = new BuilderSettings { Out = OutPath.Text, Nup = romfs == null ? chosenNup : null, Romfs = romfs, Exefs = exefs }; + var s = new BuilderSettings { Out = OutPath.Text, Nup = romfs == null ? chosenNup : null, Romfs = romfs, Exefs = exefs, + PrepareLater = PrepareTick.IsChecked != true }; Directory.CreateDirectory(System.IO.Path.GetDirectoryName(SettingsPath)!); File.WriteAllText(SettingsPath, System.Text.Json.JsonSerializer.Serialize(s)); } @@ -675,6 +678,7 @@ async void StartBuild(object sender, RoutedEventArgs e) else if (chosenNup != null) args.AddRange(new[] { "--nup", chosenNup }); else return; args.AddRange(new[] { "--out", OutPath.Text, "--language", lang.Code }); + if (PrepareTick.IsChecked != true) args.Add("--no-prepare-shaders"); Run(tools, args); } @@ -685,10 +689,12 @@ static readonly (string Id, string Build, string Update)[] Stages = ("lift", "Translate the game to PC code", "Translate the game to PC code"), ("compile", "Compile", "Compile changed files"), ("finish", "Put the game together", "Swap in the new game"), + ("shaders", "Prepare the shaders", "Prepare the shaders"), }; static readonly Dictionary Weight = new() { - ["download"] = (0, 0.04), ["read"] = (0.04, 0.04), ["lift"] = (0.08, 0.14), ["compile"] = (0.22, 0.74), ["finish"] = (0.96, 0.04), + ["download"] = (0, 0.04), ["read"] = (0.04, 0.04), ["lift"] = (0.08, 0.14), ["compile"] = (0.22, 0.68), ["finish"] = (0.90, 0.02), + ["shaders"] = (0.92, 0.08), // (hookshot --prepare-shaders: minutes on some GPUs, the first start's wait moved here) }; void MakeRows(bool update) @@ -1119,7 +1125,9 @@ internal void UpdateBuild(Tools tools, string folder) { download = null; if (rows.TryGetValue("download", out var d)) { d.Done(); d.Small("Build tools checked"); } - Run(tools, new List { "--update", folder }, keepRows: true); + var args = new List { "--update", folder }; + if (PrepareTick.IsChecked != true) args.Add("--no-prepare-shaders"); // (as the last build chose: ApplySettings) + Run(tools, args, keepRows: true); } // ================= the window ================= diff --git a/builder/linux/hookshot_builder.py b/builder/linux/hookshot_builder.py index b24044e7..fe998a0a 100644 --- a/builder/linux/hookshot_builder.py +++ b/builder/linux/hookshot_builder.py @@ -172,6 +172,7 @@ def __init__(self, launch=None, update=None, fetched=False, tag=None): names = [n for _, n in make_game.LANGUAGES] code = self.cfg.get("language", "en-US") self.language = tk.StringVar(value=dict(make_game.LANGUAGES).get(code, names[0])) + self.prepare = tk.BooleanVar(value=self.cfg.get("prepare", "1") != "0") # (make_game.py's last step: off, at the first start) self.check_text = tk.StringVar() self.space_text = tk.StringVar() self.stage_text = tk.StringVar() @@ -239,6 +240,9 @@ def show_form(self): ttk.Combobox(f, textvariable=self.language, values=[n for _, n in make_game.LANGUAGES], state="readonly").grid( row=row, column=1, sticky="w", **self.pad) row += 1 + ttk.Checkbutton(f, text="Prepare shaders now (unticked: at the game's first start, behind a black window)", + variable=self.prepare).grid(row=row, column=0, columnspan=3, sticky="w", **self.pad) + row += 1 ttk.Label(f, text="Takes about 45 minutes and needs 60 GB.").grid(row=row, column=0, columnspan=3, sticky="w", **self.pad) row += 1 ttk.Label(f, textvariable=self.space_text).grid(row=row, column=0, columnspan=3, sticky="w", **self.pad) @@ -406,7 +410,8 @@ def start_make_update(self, folder): self.status.set("This builder is the latest version.") self.lookup_latest() return - cmd = [sys.executable, "-I", os.path.join(ROOT, "nx2", "make_game.py"), "--update", folder] + cmd = [sys.executable, "-I", os.path.join(ROOT, "nx2", "make_game.py"), "--update", folder] + \ + ([] if self.prepare.get() else ["--no-prepare-shaders"]) # (as the last build chose) self.heading.configure(text="Updating your game") self.stage_text.set("Compiling what changed") self.started = time.time() @@ -450,12 +455,13 @@ def build(self): except ValueError as error: return messagebox.showerror("Hookshot", str(error)) code = next((c for c, n in make_game.LANGUAGES if n == self.language.get()), "en-US") - self.cfg.update(kind=self.kind.get(), nup=self.nup.get(), romfs=self.romfs.get(), exefs=self.exefs.get(), out=out, language=code) + self.cfg.update(kind=self.kind.get(), nup=self.nup.get(), romfs=self.romfs.get(), exefs=self.exefs.get(), out=out, language=code, + prepare="1" if self.prepare.get() else "0") save_config(self.cfg) # (issue 531) Without HOOKSHOT_JOBS or RX_JOBS, make_game chooses as on Windows, from the memory free now; this window chose # fewer, 80% of the total memory at 3.5 GB each, and the CPU sat at 29% cmd = [sys.executable, "-I", os.path.join(ROOT, "nx2", "make_game.py")] + src + ["--out", out, "--language", code] + \ - ([] if jobs is None else ["--jobs", str(jobs)]) + ([] if jobs is None else ["--jobs", str(jobs)]) + ([] if self.prepare.get() else ["--no-prepare-shaders"]) self.show_progress() self.started = time.time() self.proc = subprocess.Popen(cmd, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, diff --git a/nx2/make_game.py b/nx2/make_game.py index e05bf0a3..429b7d3f 100644 --- a/nx2/make_game.py +++ b/nx2/make_game.py @@ -727,6 +727,28 @@ def seed_pipelines(out): print("the shipped pipeline list: %d KB, built before the first title" % (os.path.getsize(seed) >> 10), flush=True) +def prepare_shaders(cmd): + """The shaders prepared now, under the builder's progress bar, not at the game's first start behind a black window (minutes on + some GPUs, issue 711): hookshot --prepare-shaders (host/hos_main.c, gpu/vk/vk_main.cpp), the same work as that first start. + Its @@stage and @@progress lines go on to the builder; its own @@done and @@error would end or fail the build there, so they + go on as plain text. A PC whose Vulkan can't (exit 3), or any other failure, leaves the work to the first start, as before""" + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", + bufsize=1) + except OSError as e: + print("the shaders were not prepared now (%s): the game prepares them at its first start" % e, flush=True) + return 1 + no_throttle(getattr(p, "_handle", None)) + for line in p.stdout: + line = line.rstrip() + print(line if line.startswith(("@@stage ", "@@progress ")) or not line.startswith("@@") else line[2:], flush=True) + p.stdout.close() + code = p.wait() + if code: + print("the shaders were not prepared now (exit %d): the game prepares them at its first start" % code, flush=True) + return code + + def folder_language(out): """the language a game folder was built in: the code line of its language.txt""" try: @@ -855,6 +877,8 @@ def parse_args(): p.add_argument("--language", default="en-US", help="the game's language: a code from LANGUAGES, written to language.txt") p.add_argument("--check", action="store_true") p.add_argument("--update", help="a game folder built before: built again from the game files it keeps (the game's update offer)") + p.add_argument("--no-prepare-shaders", action="store_true", + help="leave the shaders' preparation to the game's first start (the builder's \"Prepare shaders now\" unticked)") p.add_argument("--log", metavar="FILE", help="also save build output to a UTF-8 file (replaces the file if it exists)") a = p.parse_args() a.default_work = p.get_default("work") # (main: the builder's own work folder, stop_stray_compilers) @@ -1111,6 +1135,8 @@ def compiled(line): # changed; the builder says how much stays and lets it go. Kept in a development checkout) if os.path.normcase(work) != os.path.normcase(os.path.abspath(os.path.join(ROOT, "work"))): shutil.rmtree(exefs, ignore_errors=True) + if not a.no_prepare_shaders and not os.environ.get("HOOKSHOT_NO_PREPARE"): # (either: left to the first start, as before) + prepare_shaders([os.path.join(game_dir, BESIDE[0]), "--prepare-shaders"]) total_seconds = time.time() - STARTED previous = record_times(work, run_files, compile_seconds, total_seconds, modules=plan[1], kind="update" if a.update else "build") diff --git a/tests/test_prepare_at_build.py b/tests/test_prepare_at_build.py new file mode 100644 index 00000000..69d6797b --- /dev/null +++ b/tests/test_prepare_at_build.py @@ -0,0 +1,68 @@ +"""make_game.py's last step, hookshot --prepare-shaders, relayed to the builder: its stage and progress lines go on, its own +@@done and @@error don't (the builders read those as the build's end or failure), and a failure leaves the work to the first start. + +python -I -B tests/test_prepare_at_build.py +""" +import contextlib +import importlib.util +import io +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "nx2" / "make_game.py" +spec = importlib.util.spec_from_file_location("make_game_prepare", SOURCE) +make_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(make_game) + + +def relayed(child): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = make_game.prepare_shaders([sys.executable, "-c", child]) + return code, out.getvalue().splitlines() + + +class PrepareAtBuildTests(unittest.TestCase): + def test_progress_goes_on_and_done_does_not(self): + code, lines = relayed("print('hookshot v'); print('@@stage shaders Preparing shaders'); print('@@progress 0.500');" + "print('@@done shaders')") + self.assertEqual(code, 0) + self.assertEqual(lines, ["hookshot v", "@@stage shaders Preparing shaders", "@@progress 0.500", "done shaders"]) + + def test_no_vulkan_is_not_a_failed_build(self): + code, lines = relayed("import sys; print('@@error no Vulkan device'); sys.exit(3)") + self.assertEqual(code, 3) + self.assertFalse(any(l.startswith("@@") for l in lines)) + self.assertIn("error no Vulkan device", lines) + self.assertTrue(lines[-1].startswith("the shaders were not prepared now (exit 3)")) + + def test_missing_game_is_not_a_failed_build(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = make_game.prepare_shaders([str(ROOT / "no-such-folder" / "hookshot.exe"), "--prepare-shaders"]) + self.assertEqual(code, 1) + self.assertNotIn("@@", out.getvalue()) + + def test_the_build_runs_it_after_the_game_folder_is_complete(self): + text = SOURCE.read_text(encoding="utf-8") + main = text[text.index("\ndef main("):] + at = main.index("prepare_shaders([os.path.join(game_dir, BESIDE[0]), \"--prepare-shaders\"])") + for before in ("os.replace(dst + \".new\", dst)", "shader_census_cmd(", "seed_pipelines(out)"): + self.assertLess(main.index(before), at, before) + self.assertLess(at, main.index("say(\"done\", out)")) + + def test_the_builders_can_leave_it_to_the_first_start(self): + text = SOURCE.read_text(encoding="utf-8") + self.assertIn('"--no-prepare-shaders"', text) + main = text[text.index("\ndef main("):] + guard = main[:main.index("prepare_shaders([os.path.join(game_dir")].rsplit("\n", 2)[-2] + self.assertIn("a.no_prepare_shaders", guard) + self.assertIn('HOOKSHOT_NO_PREPARE', guard) + for builder in ("builder/HookshotBuilder/MainWindow.xaml.cs", "builder/linux/hookshot_builder.py"): + self.assertEqual((ROOT / builder).read_text(encoding="utf-8").count("--no-prepare-shaders"), 2, builder) # (build and update) + + +if __name__ == "__main__": + unittest.main()