diff --git a/crates/skate-game/src/scoring_hud.rs b/crates/skate-game/src/scoring_hud.rs index 1b80d30e..4c4c79cb 100644 --- a/crates/skate-game/src/scoring_hud.rs +++ b/crates/skate-game/src/scoring_hud.rs @@ -211,9 +211,9 @@ fn setup( }, )); commands.insert_resource(hud); - info!("Original scoring HUD loaded"); + info!("Original scoring HUD loaded from {}", root.display()); } - Err(error) => error!("Original scoring HUD could not load: {error}"), + Err(error) => error!("Original scoring HUD could not load from {}: {error}. See docs/hud-installation.md", root.display()), } } // Rasterize at output pixel resolution; retain the original 1280x720 APT diff --git a/crates/skate-game/src/session_marker/hud.rs b/crates/skate-game/src/session_marker/hud.rs index 6d1d9d74..bc37bbb7 100644 --- a/crates/skate-game/src/session_marker/hud.rs +++ b/crates/skate-game/src/session_marker/hud.rs @@ -77,7 +77,7 @@ fn load( Ok(h) => h, Err(e) => { warn!( - "Original session-marker HUD unavailable at {}: {e}", + "Original session-marker HUD unavailable at {}: {e}. See docs/hud-installation.md", folder.display() ); return; @@ -123,6 +123,7 @@ fn load( error!("Invalid marker HUD geometry"); return; } + info!("Original session-marker HUD loaded from {}", folder.display()); commands .spawn((MarkerHudRoot, Transform::default(), Visibility::Hidden)) .with_children(|parent| { diff --git a/docs/hud-installation.md b/docs/hud-installation.md new file mode 100644 index 00000000..31fe4d64 --- /dev/null +++ b/docs/hud-installation.md @@ -0,0 +1,47 @@ +# Original HUD installation + +The runtime reads both caches from the selected `--assets` directory: + +| HUD | Default manifest | Optional development override | +| --- | --- | --- | +| Trick scoring | `private/hud/runtime/trickdisplay.json` | `SKATE_SCORING_HUD_ROOT` | +| Session marker | `private/session-marker/hud.json` | `SKATE3_SESSION_MARKER_OVERLAY` | + +Merging HUD code does not install ignored, extracted game assets. Earlier feature +launchers used private caches through environment overrides; the combined build +with only `--assets` could not find either cache. Setup now prepares both with +the existing extractors. The marker extractor defaults to the vendored UI toolkit; +the external `--ui-toolkit` argument remains supported. + +For an existing installation, reuse prepared caches without reconverting maps: + +```powershell +python tools/install_prepared_hud.py --assets --scoring-cache --marker-cache +``` + +This validates both manifests and referenced texture sizes before copying any +files, checks available marker texture hashes, and verifies byte-identical copies. +It refuses differing installed files unless `--replace` is supplied. It installs +only the runtime files, preserving authored geometry, fonts, images and actions. +No copyrighted assets are committed or bundled with the source. + +If no prepared caches exist, prepare only the two HUDs from the owned game folder: + +```powershell +python tools/prepare_hud.py --game --output /private/hud --collections /private/stock/skater-collections.json +python tools/extract_session_marker.py --game --output /private/session-marker +``` + +Startup logs report the loaded paths or a load failure. Check the executable and +asset paths in the launcher as well: `PLAY.bat` delegates to `Launch.ps1`, which +selects `bin/skate3rust.exe`, not a feature build elsewhere in `bin`. + +Session-marker prompts appear while holding LB during gameplay; they are not an +always-visible panel. Scoring follows the original trickdisplay movie. Its camera +renders layer 31 offscreen before the presentation UI; the marker camera renders +layer 29 at order 1 after presentation. The existing scoring startup dependency +on `PresentationSetup` must be retained. Neither HUD uses the world mesh layers. + +The installation repair and release compilation do not establish GPU visibility +or gameplay parity. Manual checks remain: resume gameplay, hold LB for the marker +panel, and perform and land a trick for the scoring display. diff --git a/tools/extract_session_marker.py b/tools/extract_session_marker.py index 1ecbddaf..4fc77d0e 100644 --- a/tools/extract_session_marker.py +++ b/tools/extract_session_marker.py @@ -9,6 +9,7 @@ import hashlib import json from pathlib import Path import sys +import importlib # Presentation calibration against the supplied retail HUD captures. These are # host compositor adjustments, not recovered APT constants. Keep the original @@ -17,10 +18,11 @@ PANEL_OPACITY = 0.75 SHADOW_COVERAGE_GAMMA = 2.2 -def compile_hud(cache_root: Path, output: Path) -> None: - from skate3_ui_extract.scene_graph import AssetCache, SceneFlattener, transform_point - from skate3_ui_extract.bitmap_font import measure_bitmap_text - from skate3_ui_extract.timeline import playback_frame +def compile_hud(cache_root: Path, output: Path, toolkit="vendor.skate3_ui") -> None: + scene = importlib.import_module(toolkit + ".scene_graph") + AssetCache, SceneFlattener, transform_point = scene.AssetCache, scene.SceneFlattener, scene.transform_point + measure_bitmap_text = importlib.import_module(toolkit + ".bitmap_font").measure_bitmap_text + playback_frame = importlib.import_module(toolkit + ".timeline").playback_frame cache = AssetCache(cache_root) bundle = "data/fe/source/screens/hud2/hudphonelist" labels = json.loads((cache_root / "metadata/languages/english_global.json").read_text()) @@ -148,21 +150,26 @@ def compile_hud(cache_root: Path, output: Path) -> None: +def prepare(game: Path, output: Path, toolkit="vendor.skate3_ui"): + extract_project = importlib.import_module(toolkit + ".project").extract_project + cache = output / "source-cache" + extract_project(game, cache, include_dynamic=True, prefixes=( + "data/fe/source/screens/hud2/hudphonelist", "data/fe/source/controls/button_item2", + "data/fe/source/images/buttons/xbox360"), update=True) + compile_hud(cache, output, toolkit) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--game", type=Path, required=True) - parser.add_argument("--ui-toolkit", type=Path, required=True) + parser.add_argument("--ui-toolkit", type=Path, help="Optional external toolkit; defaults to the vendored extractor") parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() if args.output.resolve().is_relative_to(args.game.resolve()): parser.error("Output must be outside the owned game source") - sys.path.insert(0, str(args.ui_toolkit.resolve())) - from skate3_ui_extract.project import extract_project - cache = args.output / "source-cache" - extract_project(args.game, cache, include_dynamic=True, prefixes=( - "data/fe/source/screens/hud2/hudphonelist", "data/fe/source/controls/button_item2", - "data/fe/source/images/buttons/xbox360"), update=True) - compile_hud(cache, args.output) + if args.ui_toolkit: + sys.path.insert(0, str(args.ui_toolkit.resolve())) + prepare(args.game, args.output, "skate3_ui_extract" if args.ui_toolkit else "vendor.skate3_ui") print(f"Private original session-marker HUD: {args.output / 'hud.json'}") diff --git a/tools/install_prepared_hud.py b/tools/install_prepared_hud.py new file mode 100644 index 00000000..bba8ba50 --- /dev/null +++ b/tools/install_prepared_hud.py @@ -0,0 +1,90 @@ +"""Install already extracted original HUD caches into an existing asset root. + +Copies only runtime manifests and referenced RGBA files, byte for byte. No game, +disc extraction, map conversion or renderer is started. Both caches are checked +before writing; existing differing files require an explicit --replace. +""" +from pathlib import Path +import argparse +import hashlib +import json +import shutil + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def contained(root, relative): + path = (root / relative).resolve() + if not path.is_relative_to(root.resolve()) or path == root.resolve(): + raise ValueError(f"HUD path escapes cache: {relative}") + return path + + +def runtime_files(root, marker=False): + manifest = "hud.json" if marker else "runtime/trickdisplay.json" + data = json.loads((root / manifest).read_text(encoding="utf-8")) + if data.get("version") != 1: + raise ValueError(f"Unsupported HUD version: {root}") + textures = [] + if marker: + if data.get("canvas") != [1280, 720] or not data.get("meshes"): + raise ValueError("Invalid session-marker HUD") + textures = [(t["file"], t["width"], t["height"], t.get("output_sha256")) + for t in data["textures"]] + else: + if data.get("format") != "skate3-scoring-hud" or data.get("unresolved_fonts"): + raise ValueError("Invalid scoring HUD or unresolved original fonts") + for shape in data["shapes"].values(): + for primitive in shape: + t = primitive["texture"] + textures.append((t["rgba"], t["width"], t["height"], None)) + for font in data["fonts"].values(): + size = font["definition"]["textures"][0] + textures.append((font["texture"], size["width"], size["height"], None)) + files = {manifest: digest(root / manifest)} + for relative, width, height, expected in textures: + path = contained(root, relative) + if width <= 0 or height <= 0 or path.stat().st_size != width * height * 4: + raise ValueError(f"Invalid RGBA size: {path}") + actual = digest(path) + if expected and expected != actual: + raise ValueError(f"Original HUD texture hash mismatch: {path}") + files[relative] = actual + return files + + +def install(assets, scoring_cache, marker_cache, replace=False): + plan = [] + for source, name, marker in [(scoring_cache, "hud", False), + (marker_cache, "session-marker", True)]: + destination = assets / "private" / name + for relative, sha256 in runtime_files(source, marker).items(): + target = contained(destination, relative) + if target.exists() and digest(target) != sha256 and not replace: + raise ValueError(f"Different installed HUD file (use --replace): {target}") + plan.append((contained(source, relative), target, sha256)) + for source, target, sha256 in plan: + if target.exists() and digest(target) == sha256: + continue + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + if digest(target) != sha256: + raise ValueError(f"Installed HUD hash mismatch: {target}") + return {str(target.relative_to(assets.resolve())): sha for _, target, sha in plan} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--assets", type=Path, required=True) + parser.add_argument("--scoring-cache", type=Path, required=True) + parser.add_argument("--marker-cache", type=Path, required=True) + parser.add_argument("--replace", action="store_true") + args = parser.parse_args() + files = install(args.assets, args.scoring_cache, args.marker_cache, args.replace) + print(json.dumps({"assets": str(args.assets.resolve()), "files": files}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_install_prepared_hud.py b/tools/test_install_prepared_hud.py new file mode 100644 index 00000000..37ad47b5 --- /dev/null +++ b/tools/test_install_prepared_hud.py @@ -0,0 +1,74 @@ +import hashlib +import json +from pathlib import Path +import tempfile +import unittest + +from install_prepared_hud import install + + +class HudInstallationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + self.assets, self.score, self.marker = [root / p for p in ("assets", "score", "marker")] + (self.score / "runtime").mkdir(parents=True) + self.marker.mkdir() + self.pixels = bytes([12, 34, 56, 78]) + for cache in (self.score, self.marker): + (cache / "atlas.rgba").write_bytes(self.pixels) + self.score_json = {"format": "skate3-scoring-hud", "version": 1, + "shapes": {"1": [{"texture": {"rgba": "atlas.rgba", "width": 1, "height": 1}}]}, + "fonts": {"native": {"texture": "atlas.rgba", "definition": { + "textures": [{"width": 1, "height": 1}]}}}} + (self.score / "runtime/trickdisplay.json").write_text(json.dumps(self.score_json)) + self.marker_json = {"version": 1, "canvas": [1280, 720], "meshes": [{}], + "textures": [{"file": "atlas.rgba", "width": 1, "height": 1, + "output_sha256": hashlib.sha256(self.pixels).hexdigest()}]} + self.write_marker() + + def write_marker(self): + (self.marker / "hud.json").write_text(json.dumps(self.marker_json)) + + def run_install(self, **kwargs): + return install(self.assets, self.score, self.marker, **kwargs) + + def test_preserves_bytes_and_repeat_install_is_a_noop(self): + files = self.run_install() + before = {p: (self.assets / p).stat().st_mtime_ns for p in files} + self.assertEqual(files, self.run_install()) + self.assertEqual(before, {p: (self.assets / p).stat().st_mtime_ns for p in files}) + self.assertEqual((self.assets / "private/hud/atlas.rgba").read_bytes(), self.pixels) + self.assertEqual((self.assets / "private/session-marker/hud.json").read_bytes(), + (self.marker / "hud.json").read_bytes()) + + def test_invalid_second_cache_does_not_partially_install_first(self): + (self.marker / "atlas.rgba").write_bytes(b"bad") + with self.assertRaisesRegex(ValueError, "RGBA size"): + self.run_install() + self.assertFalse(self.assets.exists()) + + def test_hash_and_path_failures(self): + (self.marker / "atlas.rgba").write_bytes(b"fake") + with self.assertRaisesRegex(ValueError, "hash mismatch"): + self.run_install() + self.marker_json["textures"][0]["file"] = "../outside.rgba" + self.write_marker() + with self.assertRaisesRegex(ValueError, "escapes cache"): + self.run_install() + self.assertFalse(self.assets.exists()) + + def test_differing_existing_file_requires_replace(self): + self.run_install() + target = self.assets / "private/hud/atlas.rgba" + target.write_bytes(b"keep") + with self.assertRaisesRegex(ValueError, "Different installed"): + self.run_install() + self.assertEqual(target.read_bytes(), b"keep") + self.run_install(replace=True) + self.assertEqual(target.read_bytes(), self.pixels) + + +if __name__ == "__main__": + unittest.main()