From 128b40d1e87bfb6716fec2e3361aa2c870207e1e Mon Sep 17 00:00:00 2001 From: chazmm Date: Thu, 10 Sep 2026 01:24:11 +0100 Subject: [PATCH] Fix default.xex refresh flow and cache verified character preparation stages --- crates/skate-game/src/setup.rs | 44 ++- docs/installation.md | 47 ++- docs/setup-preparation-performance.md | 113 ++++++++ tools/asset_pipeline/asset_exports.py | 77 +++++ tools/asset_pipeline/character_glb.py | 14 +- tools/asset_pipeline/customisation_library.py | 12 +- tools/asset_pipeline/customiser_cache.py | 116 ++++++++ tools/asset_pipeline/customiser_lighting.py | 4 +- tools/asset_pipeline/customiser_setup.py | 81 ++++-- tools/asset_pipeline/group_receipts.py | 67 +++++ tools/asset_pipeline/install.py | 267 +++++++++--------- .../asset_pipeline/pipeline-equivalence.json | 52 ++++ tools/asset_pipeline/retail_character.py | 4 +- tools/asset_pipeline/setup_state.py | 80 ++++++ tools/asset_pipeline/test_customiser_setup.py | 27 +- tools/asset_pipeline/test_setup_recovery.py | 92 ++++++ tools/asset_pipeline/test_versions.py | 40 ++- tools/asset_pipeline/versions.py | 27 +- tools/extract_default_skater.py | 11 + tools/setup.py | 17 +- 20 files changed, 979 insertions(+), 213 deletions(-) create mode 100644 docs/setup-preparation-performance.md create mode 100644 tools/asset_pipeline/asset_exports.py create mode 100644 tools/asset_pipeline/customiser_cache.py create mode 100644 tools/asset_pipeline/group_receipts.py create mode 100644 tools/asset_pipeline/pipeline-equivalence.json create mode 100644 tools/asset_pipeline/setup_state.py create mode 100644 tools/asset_pipeline/test_setup_recovery.py diff --git a/crates/skate-game/src/setup.rs b/crates/skate-game/src/setup.rs index c52fb796..aa558807 100644 --- a/crates/skate-game/src/setup.rs +++ b/crates/skate-game/src/setup.rs @@ -10,11 +10,18 @@ fn installed(base: &Path) -> Result, String let marker: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; if marker["version"].as_u64() != Some(1) { return Err("Unsupported installation version".into()); } let relative = Path::new(marker["directory"].as_str().ok_or("Invalid installation path")?); - if relative.is_absolute() || relative.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { + let parts: Vec<_> = relative.components().collect(); + if parts.len() != 2 || parts[0].as_os_str() != "installations" + || !parts[1].as_os_str().to_str().is_some_and(|s| s.len() == 32 + && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) { return Err("Invalid installation path".into()); } let assets = base.join(relative).join("assets"); - if !assets.join("private/game.json").is_file() { return Ok(None); } + if !assets.is_dir() { return Ok(None); } + if !assets.canonicalize().map_err(|e| e.to_string())? + .starts_with(base.canonicalize().map_err(|e| e.to_string())?) { + return Err("Installation escapes its package data directory".into()); + } Ok(Some((assets, marker))) } @@ -37,6 +44,9 @@ pub(crate) fn asset_root() -> Result { let existing = installed(&base)?; if let Some((assets, marker)) = &existing { if expected.as_ref().is_none_or(|versions| marker.get("pipelines") == Some(versions)) + && assets.join("private/game.json").is_file() + && marker.get("outputs").is_none_or(|groups| groups.as_object().is_some_and(|groups| + groups.values().all(|files| receipt_present(assets.parent().unwrap(), files)))) && customiser_current(assets, expected_customiser.as_deref()) { return Ok(assets.clone()); } @@ -72,5 +82,35 @@ fn customiser_current(assets: &Path, expected: Option<&str>) -> bool { && v["set"].as_str().is_some_and(|s| s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()))) && ["library-v3.json", "extra-menu.json", "native-lighting.json", "native-roster/complete.json"].iter() .all(|name| crate::customiser_parts::asset_directory(assets).join(name).is_file()) + && ["catalog", "library", "menu", "lighting", "roster"].iter().all(|stage| { + let directory = crate::customiser_parts::asset_directory(assets); + std::fs::read(directory.join(format!("{stage}-complete.json"))).ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .is_some_and(|v| receipt_present(&directory, &v["files"])) + }) }) } + +// Cheap launch-time completeness check. Setup verifies SHA-256 before reuse; +// hashing every map on every game launch would read gigabytes unnecessarily. +fn receipt_present(root: &Path, files: &serde_json::Value) -> bool { + files.as_object().is_some_and(|files| !files.is_empty() && files.iter().all(|(name, entry)| { + let relative = Path::new(name); + !relative.is_absolute() + && relative.components().all(|c| matches!(c, std::path::Component::Normal(_))) + && std::fs::metadata(root.join(relative)).ok() + .is_some_and(|m| m.is_file() && Some(m.len()) == entry["size"].as_u64()) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn receipts_reject_missing_files_empty_lists_and_traversal() { + let root = std::env::temp_dir(); + assert!(!receipt_present(&root, &serde_json::json!({}))); + assert!(!receipt_present(&root, &serde_json::json!({"../missing": {"size": 0}}))); + assert!(!receipt_present(&root, &serde_json::json!({"nonexistent-skate-setup-test": {"size": 0}}))); + } +} diff --git a/docs/installation.md b/docs/installation.md index ad18e739..bf08181a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,8 +5,8 @@ runtime statically; the setup helper bundles Python, NumPy, Pillow and Tcl/Tk. It also bundles a small Rust RefPack decoder. Users need no compiler. -On first launch, setup accepts a local Xbox 360 Skate 3 ISO, `default.xex`, -or an extracted game folder. Selecting `default.xex` uses the surrounding +On first launch and after an extractor update, setup asks for a local Xbox 360 +Skate 3 `default.xex` or ISO. Selecting `default.xex` uses the surrounding folder, which must still contain the full game data. Extracted copies skip ISO extraction and need no tool download. For ISO input, setup downloads the hash-pinned extract-xiso utility from XboxDev. Asset conversion runs in @@ -36,7 +36,7 @@ The conversion pipeline: This validates loading, not gameplay. Conversion intermediates are removed after their output succeeds. Failed workspaces retain logs for diagnosis. -Data lives under `%LOCALAPPDATA%/Skate3RustEngine`. `installation.json` points +Data lives in `data` beside this portable copy's executable. `installation.json` points to the completed installation. Conversion tools are cached separately, so retrying does not download them again. `setup-error.log` reports setup errors; each installation also has `setup.log` with converter output. @@ -103,19 +103,40 @@ core VLT/animation decoding invalidate dependent groups too. Shared map/material parsers can invalidate both maps and environment assets. New converter modules must be assigned to their consuming groups in `asset_pipeline/versions.py`. -Refresh reuses the Xbox source path saved for this copy, asking for its location -if it moved. ISO sources must be unpacked again when a refresh needs disc files; -unchanged asset groups are copied, not reconverted. The chosen Xbox executable +Refresh uses the same `Select ISO or default.xex` file picker as initial setup; +the player never needs to select an intermediate extraction folder. ISO sources +must be unpacked again when a refresh needs disc files; unchanged asset groups +are reused without conversion. The chosen Xbox executable must match the original edition. Source paths stay in local installation records and are never included in published release metadata. -Updates create a new generation within this copy's data directory, copy unchanged -assets/maps/settings, and run only affected conversion stages. No hardlinks are -used, so rebuilding an output cannot modify the previous generation. Runtime -asset checks must pass before the installation record is atomically replaced. -Cancellation/failure leaves the previous record and data intact. Previous data -is retained for recovery; staging needs additional disk space. A copied stale -record without fingerprints triggers a complete refresh once. +Updates run only affected exports. Core/HUD/character/environment/map recipes +have separate dependency identities; setup UI and transaction changes do not +invalidate every map. Explicit, exact old/new fingerprint equivalences cover +verified unchanged exports when migrating the earlier broad fingerprint scheme. +Unrecognized versions still refresh normally. + +Core refreshes create a new installation before publication. Unchanged prepared +maps and immutable character generations use hardlinks (copy fallback on filesystems without support); mutable user files and +outputs being rebuilt have independent storage. Customiser-only refreshes keep +the current core installation and publish a new character generation. Custom +models, profiles, settings, selections and mods are retained. Every required +stage, including the complete character library and pro roster, must succeed +before the installation record changes. Failures preserve the previous record. +A kernel lock prevents concurrent setup and releases automatically on process +exit, including a crash; the harmless `setup.lock` file can remain on disk. + +Character catalog, library, menu, lighting and roster stages have separate +source/dependency identities and SHA-256 output receipts. Completed stages can +be reused across generations or resumed after an interruption; partial stages +are rebuilt before any existence-based converter sees them. Setup verifies +checksums before reuse. Startup checks receipt paths and file sizes, avoiding a +full map checksum scan on every launch. Same-size corruption is detected during +setup validation, rather than ordinary startup. Old generations remain for +recovery; this still requires space for genuinely changed output. + +See [character preparation measurements](setup-preparation-performance.md) for +benchmark conditions, stage timings and output equivalence checks. Installations from releases that used the old global asset store need one setup in the new per-copy layout. There is deliberately no automatic global migration. diff --git a/docs/setup-preparation-performance.md b/docs/setup-preparation-performance.md new file mode 100644 index 00000000..dc8b0d94 --- /dev/null +++ b/docs/setup-preparation-performance.md @@ -0,0 +1,113 @@ +# Character setup preparation measurements + +Measured 2026-09-10 on Windows 11 Pro, Ryzen 7 9800X3D (8 cores / 16 threads), +32 GB RAM, Crucial P3 4 TB NVMe system drive. The owned Xbox source and all test +outputs were on that drive. No maps were extracted, copied or converted for +these measurements, and no game was launched. + +## Release-dependency measurement + +Python 3.13.9, NumPy 2.2.6 and Pillow 11.3.0, matching `requirements-setup.txt`. +Both versions used the same freshly compiled native RefPack library from this +checkout. The baseline Python sources were taken from commit `c8219a5`. + +| Stage | Before | After | +| --- | ---: | ---: | +| Catalog | 0.98 s | 1.18 s | +| Clothing/body/material library | 426.43 s | 97.27 s | +| Authored clothing lighting | 5.83 s | 2.04 s | +| Menu | Not timed separately | 0.02 s | +| All 41 pro/special characters | Not timed separately | 140.59 s | + +The new complete staged customiser run took **245.62 s**, including source +hashing, all five stages, output receipts and publication. There is no measured +old full-customiser total, so the library comparison must not be advertised as +a full-setup speed multiplier. The new stage timings include receipt creation; +the old timings surround the corresponding converter function. + +Each cold-output run started with no generated character library, decoded +textures or models. Only the two required, already prepared stock inputs were +reused: OnBoard.abin and the female fallback recipe. This is **not** a cold OS +cache test: filesystem caches were not flushed, and this was a development PC +with other checks running during parts of the measurements. These are single +observations, not controlled repeated trials or guarantees for other hardware. + +A subsequent generation reused matching, checksummed stages in **19.93 s** total: +catalog 0.30 s, library 17.29 s, menu 0.01 s, lighting 0.08 s, roster 0.64 s. +This included checksum verification, generation-relative JSON reference updates +and hardlinking immutable output. It demonstrates validated reuse across a +setup-code change with unchanged export identities; it is not a fresh extraction. + +## Profile and diagnosis + +Before editing converters, the full library and smaller model/texture samples +were profiled. The source-only profile used Python 3.13.9, NumPy 2.4.3 and Pillow +12.1.1 with the Python RefPack fallback. Full-library wall time was 805.53 s +before and 288.83 s after. Profiling overhead and overlapping development work +mean these figures should not be compared directly with the release-dependency +numbers above. + +The old profile spent 424.37 s in 2,920 texture decodes and 171.97 s in 480 GLB +conversions, including 83.33 s repeatedly constructing the animation source. +It parsed RX2 geometry 6,240 times. The new profile spent 66.04 s decoding the +same textures. The source fallback's archive decompression then dominated; +release packaging already supplies a native decoder, so a freshly built native +library was used for the second measurement. + +The character-only path loaded `rx2_parser.py` by filename without making its +sibling `rx2_fast.py` importable. It silently selected scalar pixel loops; +other setup stages could incidentally make the fast decoder available. Loading +the matching sibling explicitly removes that import-order dependency. A +12-texture diagnostic sample went from 0.595 s to 0.158 s. + +The library now retains one read-only animation source and reference skeleton +per invocation, and passes each item's already parsed mesh and morphs into its +GLB conversion. Item-local data is discarded each iteration, bounding memory. +Lighting resolves authored shader parameters once per model instead of once per +material variant; each variant still receives its own texture and conflict check. +No extra concurrency was added: eliminating duplicated work and ensuring the +existing vectorized decoder is used produced the measured improvement without +additional worker memory or contention. + +## Correctness and recovery evidence + +- Source-profile comparison: all 4,189 GLB/PNG outputs were byte-identical, + and library metadata was equal: 480 models, 2,427 materials, 170 tattoos. +- Release-dependency comparison: all 4,218 GLB/PNG outputs, including specular + masks, were byte-identical. Base library, final lit library and native lighting + metadata matched after normalizing the generation-relative path prefix. +- The complete new pipeline published all 41 pro/special characters successfully. + Native roster output was not separately compared against an old full roster. +- Owned-output checks verified geometry bounds, finite buffers, skin weights, + all 22 live morph targets, secondary UVs, hair opacity and referenced textures. +- Synthetic recovery tests cover default.xex validation, failed finalization, + preservation of maps/user profiles/mods, crash-released locking, incomplete + stages, same-size corruption, dependency invalidation, and a crash between + publishing a generation and deleting its pending record. +- `cargo check --locked -p skate-game --bin skate3rust --no-default-features` + passed; no game was automatically launched. Gameplay remains user-tested. + +## Reproducing stage measurements + +With setup dependencies installed, run the character helper against an explicitly +chosen prepared asset root containing its stock inputs: + +```text +python -m tools.asset_pipeline.customiser_setup --game PATH_TO_DEFAULT_XEX --assets PATH_TO_ASSETS +``` + +Stage timings and reuse flags are written inside the selected private character +generation as `timings.json`. To measure fresh output, use a separate owned test +asset root with the required stock inputs, rather than deleting working user +assets. To profile, run the same module through Python's `cProfile`. Keep derived +game content and machine-specific benchmark paths out of commits. + +`pipeline-equivalence.json` contains exact old/new pairs for the unchanged core, +HUD, default-character, environment and map exports at `c8219a5`, covering LF and +CRLF package source conventions. The export recipe split preserves the original +operations. Character converter reuse and decoder dispatch were checked by the +byte comparisons above; map exporters were not modified. New fingerprints +normalize text line endings. Each equivalence is bound to its specific new +fingerprint, so later exporter edits cannot inherit an old bypass. Unknown +historical fingerprints still require conversion; no general stale-version +exception exists. diff --git a/tools/asset_pipeline/asset_exports.py b/tools/asset_pipeline/asset_exports.py new file mode 100644 index 00000000..b0740c14 --- /dev/null +++ b/tools/asset_pipeline/asset_exports.py @@ -0,0 +1,77 @@ +"""Owned-disc exports, separate from setup transaction orchestration.""" +import json +import shutil +from pathlib import Path +from . import install as engine +from .vlt import convert as convert_vlt +from .physics_skeleton import convert as convert_skeleton + +def core(game_root, stage, work, report, log, converted=None): + private=stage/"assets/private" + stock=private/"stock" + report('Extracting animation banks, graphs and gameplay inputs') + engine.extract(game_root/'data/big/miscload.big',stock) + engine.extract(game_root/'data/big/miscboot.big',stock,lambda e:e.path.lower()=='data/config/input.cfg') + # Some disc banks are loose files rather than members of miscload. + loose=game_root/'data/anim' + if loose.is_dir():shutil.copytree(loose,stock/'data/anim',dirs_exist_ok=True) + report('Converting physics and difficulty settings') + database=work/'database' + engine.extract(game_root/'data/big/db.big',database,lambda e:Path(e.path).name.lower() in { + 'skaterschema.bin','skaterschema.vlt','skatercollections.bin','skatercollections.vlt'}) + names=(engine.TOOLS/'asset_pipeline/names.txt').read_text(encoding='utf-8').splitlines() + converted=convert_vlt(database/'data/db/skaterschema',database/'data/db/skatercollections',names) + (stock/'skater-collections.json').write_text(json.dumps(converted),encoding='utf-8') + skeleton=convert_skeleton(stock/'data/anim/OnBoard.abin') + (stock/'physics-skeletons.json').write_text(json.dumps(skeleton),encoding='utf-8') + return converted + + +def hud(game_root, stage, work, report, log, converted=None): + private=stage/"assets/private" + stock=private/"stock" + for folder in (private/'hud',private/'session-marker'): + if folder.is_dir():engine.remove_intermediate(folder,stage) + report('Preparing original scoring and session-marker HUD assets') + engine.run(engine.task(engine.TOOLS/'prepare_runtime_huds.py', '--game', game_root, + '--assets', stage/'assets', '--work', work/'hud'), log, report) + + +def character(game_root, stage, work, report, log, converted=None): + private=stage/"assets/private" + stock=private/"stock" + report('Preparing the skater model and textures') + manifest=json.loads((engine.TOOLS/'default_skater_retail_manifest.json').read_text()) + needed=set() + for c in manifest['components']: + needed.add(f"data/content/createacharacter/model/cas_db/{c['slot']}/0x{c['model_id']}.rx2".lower()) + needed.update(f'data/content/createacharacter/texture/0x{x}.rx2'.lower() for x in c['textures'].values()) + engine.extract(game_root/'data/content/createacharacter.big',stock,lambda e:e.path.lower() in needed) + character=work/'character' + engine.run(engine.task(engine.TOOLS/'extract_default_skater.py','--owned-data-root',stock,'--work-root',character, + '--private-root',private/'default_skater','--utt-root',engine.TOOLS/'vendor/utt'),log,report) + report('Building the skater model and rig') + from .character_glb import convert as write_character + write_character(character/'selected/models',private,manifest) + from .character_lighting import convert as write_character_lighting + write_character_lighting(character/'selected/models',private,converted) + game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000', + 'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph', + 'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'} + (private/'game.json').write_text(json.dumps(game_manifest),encoding='utf-8') + + +def environment(game_root, stage, work, report, log, converted=None): + private=stage/"assets/private" + stock=private/"stock" + report('Preparing retail sky domes') + from .sky import convert as write_skies + write_skies(game_root,stage/'assets',converted) + from .render_parameters import convert as write_render_parameters + write_render_parameters(stage/'assets',converted) + report('Extracting original travel destinations and location names') + from .teleports import convert as write_teleports + write_teleports(game_root,stage/'assets',converted) + report('Preparing global foliage backdrops') + from .backdrop import convert as write_backdrops + write_backdrops(game_root,stage/'assets',converted) diff --git a/tools/asset_pipeline/character_glb.py b/tools/asset_pipeline/character_glb.py index d1943350..3069e2a2 100644 --- a/tools/asset_pipeline/character_glb.py +++ b/tools/asset_pipeline/character_glb.py @@ -32,11 +32,12 @@ class Glb: path.write_bytes(struct.pack('